feat: add bulk hotel import, 7-day price history filter, and card status indicators
This commit is contained in:
113
src/app/api/hotels/bulk/route.ts
Normal file
113
src/app/api/hotels/bulk/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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) {
|
||||
try {
|
||||
const { urls, bulk_text, trip_id } = await req.json();
|
||||
|
||||
let rawUrls: string[] = [];
|
||||
if (Array.isArray(urls)) {
|
||||
rawUrls = urls;
|
||||
} else if (typeof bulk_text === 'string') {
|
||||
rawUrls = bulk_text
|
||||
.split(/[\r\n,\s]+/)
|
||||
.map(u => u.trim())
|
||||
.filter(u => u.length > 0);
|
||||
}
|
||||
|
||||
// Filter valid Booking.com URLs
|
||||
const validUrls = Array.from(
|
||||
new Set(
|
||||
rawUrls.filter(u => {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return parsed.hostname.includes('booking.com') && parsed.pathname.includes('/hotel/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Keine gültigen Booking.com Hotel-Links gefunden.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const insertedHotels = [];
|
||||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||
let scrapeQueue: any = null;
|
||||
|
||||
if (bullmq && bullmq.Queue) {
|
||||
const { Queue } = bullmq;
|
||||
const connection = {
|
||||
host: process.env.REDIS_HOST || '172.17.0.1',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
connectTimeout: 5000,
|
||||
maxRetriesPerRequest: 3,
|
||||
};
|
||||
scrapeQueue = new Queue('scrape-jobs', { connection });
|
||||
}
|
||||
|
||||
for (const bookingUrl of validUrls) {
|
||||
let checkin_date: string | null = null;
|
||||
let checkout_date: string | null = null;
|
||||
let hotelName = 'Neues Hotel';
|
||||
|
||||
try {
|
||||
const parsed = new URL(bookingUrl);
|
||||
checkin_date = parsed.searchParams.get('checkin');
|
||||
checkout_date = parsed.searchParams.get('checkout');
|
||||
const slugMatch = parsed.pathname.match(/\/hotel\/[a-z]{2}\/([^.]+)/i);
|
||||
if (slugMatch && slugMatch[1]) {
|
||||
hotelName = slugMatch[1]
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
} catch {}
|
||||
|
||||
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 *`,
|
||||
[hotelName, bookingUrl, checkin_date, checkout_date, trip_id || null]
|
||||
);
|
||||
|
||||
const newHotel = res.rows[0];
|
||||
insertedHotels.push(newHotel);
|
||||
|
||||
if (scrapeQueue) {
|
||||
try {
|
||||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${newHotel.id}`;
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: newHotel.id,
|
||||
url: newHotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: null,
|
||||
});
|
||||
} catch (queueErr: any) {
|
||||
console.error(`Fehler beim Einreihen von Hotel ${newHotel.id}:`, queueErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scrapeQueue) {
|
||||
await scrapeQueue.close().catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${insertedHotels.length} Hotel(s) erfolgreich importiert und zur Erfassung eingereiht!`,
|
||||
count: insertedHotels.length,
|
||||
hotels: insertedHotels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in POST /api/hotels/bulk:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
702
src/app/page.tsx
702
src/app/page.tsx
@@ -124,12 +124,18 @@ export default function TravelDashboard() {
|
||||
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||||
const [newTripName, setNewTripName] = useState('');
|
||||
const [showAddHotelModal, setShowAddHotelModal] = useState(false);
|
||||
const [addHotelMode, setAddHotelMode] = useState<'single' | 'bulk'>('single');
|
||||
const [newBookingUrl, setNewBookingUrl] = useState('');
|
||||
const [newHotelName, setNewHotelName] = useState('');
|
||||
const [newHotelTripId, setNewHotelTripId] = useState<string>('');
|
||||
const [bulkBookingUrls, setBulkBookingUrls] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmittingBulk, setIsSubmittingBulk] = useState(false);
|
||||
const [showParkedModal, setShowParkedModal] = useState(false);
|
||||
|
||||
// Preishistorie Zeitraum Filter (7 Tage vs Gesamter Verlauf)
|
||||
const [priceHistoryRange, setPriceHistoryRange] = useState<'7d' | 'all'>('7d');
|
||||
|
||||
// Scraper Debug-Modus Toggle
|
||||
const [isDebugMode, setIsDebugMode] = useState(false);
|
||||
|
||||
@@ -578,6 +584,8 @@ export default function TravelDashboard() {
|
||||
setNewHotelTripId(activeTripId !== 'ALL' ? activeTripId : (trips[0] ? String(trips[0].id) : ''));
|
||||
setNewBookingUrl('');
|
||||
setNewHotelName('');
|
||||
setBulkBookingUrls('');
|
||||
setAddHotelMode('single');
|
||||
setShowAddHotelModal(true);
|
||||
};
|
||||
|
||||
@@ -612,6 +620,36 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAddHotels = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!bulkBookingUrls.trim()) return alert('Bitte mindestens eine Booking.com URL einfügen');
|
||||
setIsSubmittingBulk(true);
|
||||
try {
|
||||
const targetTripId = newHotelTripId || null;
|
||||
const res = await fetch('/api/hotels/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bulk_text: bulkBookingUrls.trim(),
|
||||
trip_id: targetTripId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert(data.message || `${data.count} Hotels erfolgreich importiert!`);
|
||||
setBulkBookingUrls('');
|
||||
setShowAddHotelModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert('Fehler beim Bulk-Import: ' + err.message);
|
||||
} finally {
|
||||
setIsSubmittingBulk(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parkHotel = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/hotels/${id}`, {
|
||||
@@ -1349,25 +1387,64 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Card Actions Footer */}
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.8rem', paddingTop: '0.6rem', borderTop: '1px solid #f1f5f9', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => openRatingModal(hotel)}
|
||||
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', color: '#334155' }}
|
||||
>
|
||||
💬 Stimmen ({hotel.ratings_json?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openHotelDetails(hotel, 'reviews')}
|
||||
style={{ background: '#f8fafc', border: '1px solid #cbd5e1', color: '#334155', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
|
||||
>
|
||||
🤖 Reviews
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openHotelDetails(hotel, 'history')}
|
||||
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
|
||||
>
|
||||
📈 Preishistorie
|
||||
</button>
|
||||
{(() => {
|
||||
const hasReviews = Boolean(hotel.review_summary_positive || hotel.review_summary_negative || hotel.review_summary_passable || hotel.reviews_updated_at);
|
||||
const hasPrices = Boolean(hotel.latest_price !== null && hotel.latest_price !== undefined && Number(hotel.latest_price) > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.8rem', paddingTop: '0.6rem', borderTop: '1px solid #f1f5f9', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => openRatingModal(hotel)}
|
||||
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', color: '#334155' }}
|
||||
>
|
||||
💬 Stimmen ({hotel.ratings_json?.length || 0})
|
||||
</button>
|
||||
|
||||
{/* REVIEW BUTTON INDICATOR */}
|
||||
<button
|
||||
onClick={() => openHotelDetails(hotel, 'reviews')}
|
||||
style={{
|
||||
background: hasReviews ? '#ecfdf5' : '#f8fafc',
|
||||
border: hasReviews ? '1.5px solid #10b981' : '1px solid #cbd5e1',
|
||||
color: hasReviews ? '#047857' : '#64748b',
|
||||
padding: '0.35rem 0.55rem',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: hasReviews ? '800' : '600',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
boxShadow: hasReviews ? '0 1px 2px rgba(16,185,129,0.15)' : 'none'
|
||||
}}
|
||||
title={hasReviews ? 'KI-Reviews wurden bereits erfolgreich erfasst' : 'KI-Reviews wurden noch nicht erfasst'}
|
||||
>
|
||||
<span>{hasReviews ? '✅' : '🤖'}</span>
|
||||
<span>{hasReviews ? `Reviews (${hotel.review_summary_count || hotel.review_count || 0})` : 'Reviews (fehlt)'}</span>
|
||||
</button>
|
||||
|
||||
{/* PREISHISTORIE BUTTON INDICATOR */}
|
||||
<button
|
||||
onClick={() => openHotelDetails(hotel, 'history')}
|
||||
style={{
|
||||
background: hasPrices ? '#eff6ff' : '#f8fafc',
|
||||
border: hasPrices ? '1.5px solid #3b82f6' : '1px solid #cbd5e1',
|
||||
color: hasPrices ? '#1d4ed8' : '#64748b',
|
||||
padding: '0.35rem 0.55rem',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: hasPrices ? '800' : '600',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
boxShadow: hasPrices ? '0 1px 2px rgba(59,130,246,0.15)' : 'none'
|
||||
}}
|
||||
title={hasPrices ? `Preise erfasst (Aktuell: ${Number(hotel.latest_price).toLocaleString('de-DE')} €)` : 'Noch keine Preise erfasst'}
|
||||
>
|
||||
<span>📈</span>
|
||||
<span>{hasPrices ? 'Preishistorie' : 'Preishistorie (fehlt)'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refreshHotelMetadata(hotel)}
|
||||
disabled={refreshingMetadataHotelId === hotel.id || !hotel.is_active}
|
||||
@@ -1405,7 +1482,9 @@ export default function TravelDashboard() {
|
||||
Booking ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1624,176 +1703,285 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: PREISHISTORIE (MIT WIEDERHERGESTELLTEM 2-KURVEN-CHART) */}
|
||||
{/* TAB 2: PREISHISTORIE (7-TAGE RESTRIKTION, GESAMTTREND-TOGGLE & SAUBERES CHART) */}
|
||||
{detailActiveTab === 'history' && (
|
||||
<div>
|
||||
{loadingPrices ? (
|
||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Lade Preishistorie...</p>
|
||||
) : priceHistory.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden.</p>
|
||||
) : (
|
||||
<div>
|
||||
{/* DUAL STATS CARDS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: hasAnyGeniusPrice ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ background: '#f8fafc', padding: '0.9rem', borderRadius: '10px', border: '1px solid #cbd5e1' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#475569', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
|
||||
🏷️ Regulärer Preis
|
||||
<div style={{ textAlign: 'center', padding: '2.5rem 1rem', background: '#f8fafc', borderRadius: '12px', border: '1px dashed #cbd5e1' }}>
|
||||
<span style={{ fontSize: '2.5rem', display: 'block', marginBottom: '0.5rem' }}>📉</span>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: '700', color: '#1e293b' }}>
|
||||
Noch keine Preishistorie erfasst.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: '#64748b' }}>
|
||||
Klicke auf "Stammdaten aktualisieren" oder "Preise aktualisieren", um den ersten Messpunkt zu speichern.
|
||||
</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const last7DaysPrices = priceHistory.filter(p => new Date(p.created_at).getTime() >= sevenDaysAgo);
|
||||
const activePriceHistory = priceHistoryRange === '7d'
|
||||
? (last7DaysPrices.length > 0 ? last7DaysPrices : priceHistory)
|
||||
: priceHistory;
|
||||
|
||||
const activePricesList = activePriceHistory.map(p => Number(p.price)).filter(p => p > 0);
|
||||
const chartMinPrice = activePricesList.length > 0 ? Math.min(...activePricesList) : 0;
|
||||
const chartMaxPrice = activePricesList.length > 0 ? Math.max(...activePricesList) : 0;
|
||||
const chartCurrentPrice = activePricesList.length > 0 ? activePricesList[activePricesList.length - 1] : 0;
|
||||
|
||||
const activeGeniusPricesList = activePriceHistory
|
||||
.map(p => (p.genius_price !== null && p.genius_price !== undefined ? Number(p.genius_price) : null))
|
||||
.filter((p): p is number => p !== null && p > 0);
|
||||
const chartMinGenius = activeGeniusPricesList.length > 0 ? Math.min(...activeGeniusPricesList) : null;
|
||||
const chartMaxGenius = activeGeniusPricesList.length > 0 ? Math.max(...activeGeniusPricesList) : null;
|
||||
const chartCurrentGenius = activeGeniusPricesList.length > 0 ? activeGeniusPricesList[activeGeniusPricesList.length - 1] : null;
|
||||
const hasChartGenius = activeGeniusPricesList.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ZEITRAUM FILTER TOGGLE BUTTONS */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.2rem', flexWrap: 'wrap', gap: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: '800', color: '#0f172a' }}>
|
||||
Ansicht:
|
||||
</span>
|
||||
<span style={{ fontSize: '0.8rem', color: '#64748b' }}>
|
||||
({activePriceHistory.length} von {priceHistory.length} Messpunkten)
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<small style={{ color: '#166534', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Tiefst</small>
|
||||
<strong style={{ color: '#15803d', fontSize: '1rem' }}>{minPrice} €</strong>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<small style={{ color: '#1e40af', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
|
||||
<strong style={{ color: '#1d4ed8', fontSize: '1rem' }}>{currentPrice} €</strong>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<small style={{ color: '#991b1b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
|
||||
<strong style={{ color: '#b91c1c', fontSize: '1rem' }}>{maxPrice} €</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', background: '#f1f5f9', padding: '0.25rem', borderRadius: '8px', border: '1px solid #cbd5e1' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPriceHistoryRange('7d')}
|
||||
style={{
|
||||
padding: '0.35rem 0.8rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: priceHistoryRange === '7d' ? '#2563eb' : 'transparent',
|
||||
color: priceHistoryRange === '7d' ? '#ffffff' : '#475569',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
📅 Letzte 7 Tage ({last7DaysPrices.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPriceHistoryRange('all')}
|
||||
style={{
|
||||
padding: '0.35rem 0.8rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: priceHistoryRange === 'all' ? '#2563eb' : 'transparent',
|
||||
color: priceHistoryRange === 'all' ? '#ffffff' : '#475569',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
📈 Gesamter Verlauf ({priceHistory.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasAnyGeniusPrice && (
|
||||
<div style={{ background: '#f0f7ff', padding: '0.9rem', borderRadius: '10px', border: '1.5px solid #93c5fd' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#003580', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
|
||||
💙 Genius Vorteil (-10%)
|
||||
{/* DUAL STATS CARDS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: hasChartGenius ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ background: '#f8fafc', padding: '0.9rem', borderRadius: '10px', border: '1px solid #cbd5e1' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#475569', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
|
||||
🏷️ Regulärer Preis {priceHistoryRange === '7d' ? '(7 Tage)' : '(Gesamt)'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
|
||||
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Genius Min</small>
|
||||
<strong style={{ color: '#003580', fontSize: '1rem' }}>{minGeniusPrice} € 🔥</strong>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<small style={{ color: '#166534', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Tiefst</small>
|
||||
<strong style={{ color: '#15803d', fontSize: '1rem' }}>{chartMinPrice} €</strong>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
|
||||
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
|
||||
<strong style={{ color: '#003580', fontSize: '1rem' }}>{currentGeniusPrice} €</strong>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<small style={{ color: '#1e40af', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
|
||||
<strong style={{ color: '#1d4ed8', fontSize: '1rem' }}>{chartCurrentPrice} €</strong>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
|
||||
<small style={{ color: '#64748b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
|
||||
<strong style={{ color: '#64748b', fontSize: '1rem' }}>{maxGeniusPrice} €</strong>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<small style={{ color: '#991b1b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
|
||||
<strong style={{ color: '#b91c1c', fontSize: '1rem' }}>{chartMaxPrice} €</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChartGenius && (
|
||||
<div style={{ background: '#f0f7ff', padding: '0.9rem', borderRadius: '10px', border: '1.5px solid #93c5fd' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#003580', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
|
||||
💙 Genius Vorteil (-10%) {priceHistoryRange === '7d' ? '(7 Tage)' : '(Gesamt)'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
|
||||
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Genius Min</small>
|
||||
<strong style={{ color: '#003580', fontSize: '1rem' }}>{chartMinGenius} € 🔥</strong>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
|
||||
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
|
||||
<strong style={{ color: '#003580', fontSize: '1rem' }}>{chartCurrentGenius} €</strong>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
|
||||
<small style={{ color: '#64748b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
|
||||
<strong style={{ color: '#64748b', fontSize: '1rem' }}>{chartMaxGenius} €</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2-KURVEN SVG-CHART */}
|
||||
{activePriceHistory.length > 1 ? (
|
||||
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '10px', border: '1px solid #e2e8f0', marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '1.2rem', justifyContent: 'center', marginBottom: '0.8rem', fontSize: '0.78rem', fontWeight: '700' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<div style={{ width: '14px', height: '3px', background: '#2563eb' }}></div>
|
||||
<span style={{ color: '#2563eb' }}>Regulärer Preis</span>
|
||||
</div>
|
||||
{hasChartGenius && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<div style={{ width: '14px', height: '3px', background: '#003580' }}></div>
|
||||
<span style={{ color: '#003580' }}>Genius Preis (-10%)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg viewBox="0 0 500 160" style={{ width: '100%', height: '180px', overflow: 'visible' }}>
|
||||
<line x1="0" y1="20" x2="500" y2="20" stroke="#e5e7eb" strokeDasharray="4" />
|
||||
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
|
||||
{(() => {
|
||||
const allValues = [
|
||||
...activePricesList,
|
||||
...activeGeniusPricesList
|
||||
];
|
||||
const chartMin = allValues.length > 0 ? Math.min(...allValues) : 0;
|
||||
const chartMax = allValues.length > 0 ? Math.max(...allValues) : 100;
|
||||
const range = (chartMax - chartMin) || 1;
|
||||
|
||||
const normalPoints = activePriceHistory.map((p, idx) => {
|
||||
const x = (idx / (activePriceHistory.length - 1)) * 500;
|
||||
const y = 130 - ((Number(p.price) - chartMin) / range) * 105;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
const geniusPoints = activePriceHistory
|
||||
.map((p, idx) => {
|
||||
if (p.genius_price === null || p.genius_price === undefined) return null;
|
||||
const x = (idx / (activePriceHistory.length - 1)) * 500;
|
||||
const y = 130 - ((Number(p.genius_price) - chartMin) / range) * 105;
|
||||
return { x, y, val: p.genius_price, id: p.id, date: p.created_at };
|
||||
})
|
||||
.filter((pt): pt is { x: number; y: number; val: number; id: string; date: string } => pt !== null);
|
||||
|
||||
const geniusPolylineStr = geniusPoints.map(pt => `${pt.x},${pt.y}`).join(' ');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Normal Line */}
|
||||
<polyline fill="none" stroke="#2563eb" strokeWidth="2.5" strokeDasharray="4,3" points={normalPoints} />
|
||||
{activePriceHistory.map((p, idx) => {
|
||||
const x = (idx / (activePriceHistory.length - 1)) * 500;
|
||||
const y = 130 - ((Number(p.price) - chartMin) / range) * 105;
|
||||
const isMin = Number(p.price) === chartMinPrice;
|
||||
const isMax = Number(p.price) === chartMaxPrice;
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === activePriceHistory.length - 1;
|
||||
// Only show label if few points or on key landmarks (Min, Max, First, Last) to avoid collision
|
||||
const showLabel = activePriceHistory.length <= 8 || isMin || isMax || isFirst || isLast;
|
||||
|
||||
return (
|
||||
<g key={`norm-${p.id || idx}`}>
|
||||
<circle cx={x} cy={y} r={showLabel ? 4.5 : 3} fill="#2563eb" stroke="#ffffff" strokeWidth="1.5">
|
||||
<title>{`${Number(p.price).toLocaleString('de-DE')} € am ${new Date(p.created_at).toLocaleString('de-DE')}`}</title>
|
||||
</circle>
|
||||
{showLabel && (
|
||||
<text x={x} y={y - 8} fontSize="9" textAnchor="middle" fill="#1e40af" fontWeight="bold">
|
||||
{p.price}€
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Genius Line */}
|
||||
{geniusPoints.length > 1 && (
|
||||
<polyline fill="none" stroke="#003580" strokeWidth="3" points={geniusPolylineStr} />
|
||||
)}
|
||||
{geniusPoints.map((pt, idx) => {
|
||||
const isMin = Number(pt.val) === chartMinGenius;
|
||||
const isMax = Number(pt.val) === chartMaxGenius;
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === geniusPoints.length - 1;
|
||||
const showLabel = geniusPoints.length <= 8 || isMin || isMax || isFirst || isLast;
|
||||
|
||||
return (
|
||||
<g key={`gen-${pt.id || idx}`}>
|
||||
<circle cx={pt.x} cy={pt.y} r={showLabel ? 5 : 3.5} fill="#003580" stroke="#ffffff" strokeWidth="1.5">
|
||||
<title>{`Genius: ${Number(pt.val).toLocaleString('de-DE')} € am ${new Date(pt.date).toLocaleString('de-DE')}`}</title>
|
||||
</circle>
|
||||
{showLabel && (
|
||||
<text x={pt.x} y={pt.y + 14} fontSize="9" textAnchor="middle" fill="#003580" fontWeight="900">
|
||||
{pt.val}€
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: '#f8fafc', padding: '1rem 1.2rem', borderRadius: '10px', border: '1px dashed #cbd5e1', marginBottom: '1.5rem', textAlign: 'center' }}>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: '#475569', fontWeight: '600' }}>
|
||||
📌 1 Messpunkt vorhanden: <strong>{activePriceHistory[0]?.price} €</strong> ({new Date(activePriceHistory[0]?.created_at).toLocaleString('de-DE')})
|
||||
</p>
|
||||
<p style={{ margin: '0.3rem 0 0 0', fontSize: '0.8rem', color: '#94a3b8' }}>
|
||||
Sobald weitere Scans durchgeführt werden, zeichnet das System automatisch den interaktiven Verlaufschart.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2-KURVEN SVG-CHART */}
|
||||
{priceHistory.length > 1 && (
|
||||
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '10px', border: '1px solid #e2e8f0', marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '1.2rem', justifyContent: 'center', marginBottom: '0.8rem', fontSize: '0.78rem', fontWeight: '700' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<div style={{ width: '14px', height: '3px', background: '#2563eb' }}></div>
|
||||
<span style={{ color: '#2563eb' }}>Regulärer Preis</span>
|
||||
</div>
|
||||
{hasAnyGeniusPrice && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<div style={{ width: '14px', height: '3px', background: '#003580' }}></div>
|
||||
<span style={{ color: '#003580' }}>Genius Preis (-10%)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg viewBox="0 0 500 160" style={{ width: '100%', height: '170px', overflow: 'visible' }}>
|
||||
<line x1="0" y1="20" x2="500" y2="20" stroke="#e5e7eb" strokeDasharray="4" />
|
||||
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
|
||||
{(() => {
|
||||
const allValues = [
|
||||
...pricesList,
|
||||
...geniusPricesList
|
||||
];
|
||||
const chartMin = allValues.length > 0 ? Math.min(...allValues) : 0;
|
||||
const chartMax = allValues.length > 0 ? Math.max(...allValues) : 100;
|
||||
const range = (chartMax - chartMin) || 1;
|
||||
|
||||
const normalPoints = priceHistory.map((p, idx) => {
|
||||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||||
const y = 130 - ((Number(p.price) - chartMin) / range) * 110;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
const geniusPoints = priceHistory
|
||||
.map((p, idx) => {
|
||||
if (p.genius_price === null || p.genius_price === undefined) return null;
|
||||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||||
const y = 130 - ((Number(p.genius_price) - chartMin) / range) * 110;
|
||||
return { x, y, val: p.genius_price, id: p.id };
|
||||
})
|
||||
.filter((pt): pt is { x: number; y: number; val: number; id: string } => pt !== null);
|
||||
|
||||
const geniusPolylineStr = geniusPoints.map(pt => `${pt.x},${pt.y}`).join(' ');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Normal Line */}
|
||||
<polyline fill="none" stroke="#2563eb" strokeWidth="2.5" strokeDasharray="4,3" points={normalPoints} />
|
||||
{priceHistory.map((p, idx) => {
|
||||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||||
const y = 130 - ((Number(p.price) - chartMin) / range) * 110;
|
||||
return (
|
||||
<g key={`norm-${p.id || idx}`}>
|
||||
<circle cx={x} cy={y} r="4" fill="#2563eb" stroke="#ffffff" strokeWidth="1.5" />
|
||||
<text x={x} y={y - 8} fontSize="9.5" textAnchor="middle" fill="#1e40af" fontWeight="bold">
|
||||
{p.price}€
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Genius Line */}
|
||||
{geniusPoints.length > 1 && (
|
||||
<polyline fill="none" stroke="#003580" strokeWidth="3" points={geniusPolylineStr} />
|
||||
)}
|
||||
{geniusPoints.map((pt, idx) => (
|
||||
<g key={`gen-${pt.id || idx}`}>
|
||||
<circle cx={pt.x} cy={pt.y} r="4.5" fill="#003580" stroke="#ffffff" strokeWidth="1.5" />
|
||||
<text x={pt.x} y={pt.y + 14} fontSize="9.5" textAnchor="middle" fill="#003580" fontWeight="900">
|
||||
{pt.val}€
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
{/* HISTORIEN TABELLE */}
|
||||
<div style={{ maxHeight: '240px', overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: '8px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc', color: '#475569', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Datum / Uhrzeit</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Regulär</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Genius (-10%)</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Pro Nacht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activePriceHistory.slice().reverse().map((entry, idx) => {
|
||||
const hasGen = entry.genius_price !== null && entry.genius_price !== undefined;
|
||||
return (
|
||||
<tr key={entry.id || idx} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#64748b' }}>
|
||||
{new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '700', color: '#1e293b' }}>
|
||||
{Number(entry.price).toLocaleString('de-DE')} €
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '800', color: hasGen ? '#003580' : '#94a3b8' }}>
|
||||
{hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#475569' }}>
|
||||
{entry.price_per_night ? `${Number(entry.price_per_night).toLocaleString('de-DE')} €` : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HISTORIEN TABELLE */}
|
||||
<div style={{ maxHeight: '240px', overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: '8px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc', color: '#475569', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Datum / Uhrzeit</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Regulär</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Genius (-10%)</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Pro Nacht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{priceHistory.slice().reverse().map((entry, idx) => {
|
||||
const hasGen = entry.genius_price !== null && entry.genius_price !== undefined;
|
||||
return (
|
||||
<tr key={entry.id || idx} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#64748b' }}>
|
||||
{new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '700', color: '#1e293b' }}>
|
||||
{Number(entry.price).toLocaleString('de-DE')} €
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '800', color: hasGen ? '#003580' : '#94a3b8' }}>
|
||||
{hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#475569' }}>
|
||||
{entry.price_per_night ? `${Number(entry.price_per_night).toLocaleString('de-DE')} €` : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2248,43 +2436,147 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: HOTEL HINZUFÜGEN */}
|
||||
{/* MODAL: HOTEL HINZUFÜGEN (EINZELN ODER BULK) */}
|
||||
{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' }}>Reise zuordnen</label>
|
||||
<select
|
||||
value={newHotelTripId}
|
||||
onChange={e => setNewHotelTripId(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.95rem' }}
|
||||
>
|
||||
{trips.length === 0 && <option value="">Keine Reise vorhanden</option>}
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={String(trip.id)}>
|
||||
🌍 {trip.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Keiner Reise zuordnen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Booking.com URL *</label>
|
||||
<input type="url" required placeholder="https://www.booking.com/hotel/..." value={newBookingUrl} onChange={e => setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
|
||||
<input type="text" placeholder="z. B. Abad Fort (wird sonst automatisch via Link ermittelt)" value={newHotelName} onChange={e => setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" onClick={() => setShowAddHotelModal(false)} style={{ padding: '0.6rem 1.2rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
|
||||
<button type="submit" disabled={isSubmitting} style={{ padding: '0.6rem 1.2rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
|
||||
{isSubmitting ? 'Speichere & Scanne...' : 'Speichern & Scannen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div style={{ background: '#fff', padding: '2rem', borderRadius: '16px', maxWidth: '560px', width: '100%', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.35)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '1.35rem', fontWeight: '800', color: '#0f172a' }}>
|
||||
🏨 Hotel(s) hinzufügen
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddHotelModal(false)}
|
||||
style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TAB SELECTOR: EINZELN VS. BULK */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.2rem', borderBottom: '1px solid #e2e8f0', paddingBottom: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddHotelMode('single')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: addHotelMode === 'single' ? '#10b981' : '#f1f5f9',
|
||||
color: addHotelMode === 'single' ? '#ffffff' : '#475569',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
➕ Einzelner Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddHotelMode('bulk')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: addHotelMode === 'bulk' ? '#10b981' : '#f1f5f9',
|
||||
color: addHotelMode === 'bulk' ? '#ffffff' : '#475569',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
📋 Mehrere Links (Bulk Insert)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* FORMULAR: EINZELNES HOTEL */}
|
||||
{addHotelMode === 'single' ? (
|
||||
<form onSubmit={handleAddHotel} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Reise zuordnen</label>
|
||||
<select
|
||||
value={newHotelTripId}
|
||||
onChange={e => setNewHotelTripId(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.95rem' }}
|
||||
>
|
||||
{trips.length === 0 && <option value="">Keine Reise vorhanden</option>}
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={String(trip.id)}>
|
||||
🌍 {trip.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Keiner Reise zuordnen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Booking.com URL *</label>
|
||||
<input type="url" required placeholder="https://www.booking.com/hotel/..." value={newBookingUrl} onChange={e => setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
|
||||
<input type="text" placeholder="z. B. Abad Fort (wird sonst automatisch via Link ermittelt)" value={newHotelName} onChange={e => setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" onClick={() => setShowAddHotelModal(false)} style={{ padding: '0.6rem 1.2rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
|
||||
<button type="submit" disabled={isSubmitting} style={{ padding: '0.6rem 1.2rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
|
||||
{isSubmitting ? 'Speichere & Scanne...' : 'Speichern & Scannen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
/* FORMULAR: BULK INSERT */
|
||||
<form onSubmit={handleBulkAddHotels} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Reise zuordnen</label>
|
||||
<select
|
||||
value={newHotelTripId}
|
||||
onChange={e => setNewHotelTripId(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.95rem' }}
|
||||
>
|
||||
{trips.length === 0 && <option value="">Keine Reise vorhanden</option>}
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={String(trip.id)}>
|
||||
🌍 {trip.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Keiner Reise zuordnen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||
<label style={{ fontSize: '0.85rem', fontWeight: 'bold' }}>Booking.com Links (ein Link pro Zeile) *</label>
|
||||
<span style={{ fontSize: '0.75rem', color: '#059669', fontWeight: '700' }}>
|
||||
{bulkBookingUrls.split(/[\r\n]+/).filter(u => u.trim().includes('booking.com/hotel')).length} Links erkannt
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
required
|
||||
rows={6}
|
||||
placeholder={`https://www.booking.com/hotel/in/hotel-1.html\nhttps://www.booking.com/hotel/in/hotel-2.html\nhttps://www.booking.com/hotel/in/hotel-3.html`}
|
||||
value={bulkBookingUrls}
|
||||
onChange={e => setBulkBookingUrls(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.7rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: '1.4'
|
||||
}}
|
||||
/>
|
||||
<small style={{ color: '#64748b', fontSize: '0.75rem', marginTop: '0.2rem', display: 'block' }}>
|
||||
💡 Tipp: Du kannst einfach mehrere Booking.com URLs untereinander einfügen. Name und Daten werden automatisch gescrapt.
|
||||
</small>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
|
||||
<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={isSubmittingBulk || !bulkBookingUrls.trim()} style={{ padding: '0.6rem 1.3rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', opacity: (isSubmittingBulk || !bulkBookingUrls.trim()) ? 0.6 : 1 }}>
|
||||
{isSubmittingBulk ? '⏳ Importiere...' : '🚀 Alle importieren & erfassen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user