// screens-immo.jsx — Immobilien dashboard, search builder wizard, search results // ─── DASHBOARD ────────────────────────────────────────────────────────── function DashboardScreen({ ctx }) { const { listings, searches, navigate, user, taskLimit } = ctx; const purchaseSearches = searches.filter(s => s.category === 'kauf'); const bestDeals = listings .filter(l => l.type === 'kauf' && l.aiScore) .sort((a, b) => b.aiScore - a.aiScore) .slice(0, 4); const scoredListings = listings.filter(l => l.aiScore); const stats = { active: purchaseSearches.filter(s => s.active).length, matches: purchaseSearches.reduce((sum, s) => sum + s.matches, 0), newToday: purchaseSearches.reduce((sum, s) => sum + s.newToday, 0), avgScore: scoredListings.length ? Math.round(scoredListings.reduce((s, l) => s + l.aiScore, 0) / scoredListings.length) : 0, }; return (
{/* Header */}
{greeting()}, {firstName(user)}

Kaufobjekte

navigate('searchBuilder')}>
{/* Stats grid */}
{/* Active searches */}
{purchaseSearches.length === 0 ? ( } title="Noch keine Kaufsuche" subtitle="Legen Sie eine Suche an, damit ImmoBot neue Treffer sammelt." cta={} /> ) : purchaseSearches.map(s => ( navigate('results', { searchId: s.id })} /> ))}
{/* Best deals */}
navigate('results', { searchId: purchaseSearches[0]?.id })}>
{bestDeals.map((l, i) => ( navigate('listing', { id: l.id })} /> ))}
{user?.isAdmin && (
Kontingent
Aktiv
{taskLimit?.current_tasks ?? purchaseSearches.filter(s => s.active).length} aktive Suchen {taskLimit?.remaining_evaluations == null ? 'KI unbegrenzt' : `${taskLimit.remaining_evaluations} KI heute übrig`}
)}
); } function firstName(user) { const value = (user?.name || user?.email || 'Chris').trim(); return value.split(/\s+/)[0] || 'Chris'; } function greeting(date = new Date()) { const hour = date.getHours(); if (hour < 11) return 'Guten Morgen'; if (hour < 17) return 'Guten Tag'; return 'Guten Abend'; } function slugify(value) { return String(value || '') .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } function IconBtn({ children, onClick, badge }) { return ( ); } function Section({ title, trailing, onTrailing, children }) { return (
{title}
{trailing && ( )}
{children}
); } function SearchCard({ search, onTap }) { return (
{search.name}
{search.city} +{search.radius}km · {fmtPriceShort(search.priceMin)}–{fmtPriceShort(search.priceMax)} · {search.roomsMin}–{search.roomsMax} Zi.
{search.matches} Treffer {search.newToday > 0 && ( +{search.newToday} neu )} {search.aiEnabled && ( KI )} {fmtRelative(search.lastRun)}
); } // ─── SEARCH BUILDER WIZARD (4 steps) ─────────────────────────────────── function SearchBuilderScreen({ ctx, onDone, type = 'kauf' }) { const { navigate, back, addSearch } = ctx; const [step, setStep] = React.useState(1); const [saving, setSaving] = React.useState(false); const [saveError, setSaveError] = React.useState(null); const [data, setData] = React.useState({ name: '', type, city: '', locationId: '', locationSlug: '', locationDisplay: '', radius: 10, priceMin: type === 'kauf' ? 250000 : 800, priceMax: type === 'kauf' ? 550000 : 1800, roomsMin: 2, roomsMax: 4, areaMin: 60, areaMax: 120, keywords: [], excludeKeywords: [], platforms: ['immoscout24', 'kleinanzeigen'], aiEnabled: type === 'kauf', }); const total = 4; const update = (k, v) => setData(d => ({ ...d, [k]: v })); const canContinue = step !== 1 || !!data.locationId; const next = () => { if (!canContinue) { setSaveError('Bitte Stadt, Stadtteil oder PLZ aus der Standortsuche auswählen.'); return; } setSaveError(null); return step < total ? setStep(step + 1) : finish(); }; const prev = () => step > 1 ? setStep(step - 1) : back(); const finish = async () => { setSaving(true); setSaveError(null); try { const created = await addSearch({ type: data.type, property_type: 'wohnung', city: data.locationDisplay || data.city, location_id: data.locationId || null, location_slug: data.locationSlug || null, radius: data.radius, price_min: data.priceMin, price_max: data.priceMax, rooms_min: data.roomsMin, rooms_max: data.roomsMax, area_min: data.areaMin, area_max: data.areaMax, require_keywords: data.keywords, exclude_keywords: data.excludeKeywords, active: true, run_now: true, }); const id = created?.id; if (data.type === 'miete') { navigate('mietenResults', { searchId: id }, true); } else { navigate('results', { searchId: id }, true); } } catch (error) { setSaveError(error.payload?.message || error.message || 'Suche konnte nicht gespeichert werden.'); } finally { setSaving(false); } }; return (
Abbrechen} /> {/* Progress */}
{[1,2,3,4].map(i => (
))}
{step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {saveError && (
{saveError}
)}
{/* Footer */}
); } function StepHeader({ overline, title, subtitle }) { return (
{overline}

{title}

{subtitle && (

{subtitle}

)}
); } function BuilderStep1({ data, update }) { const selectLocation = (location) => { const display = location.display_name || location.name || ''; update('locationId', location.id || ''); update('locationSlug', location.slug || slugify(display)); update('locationDisplay', display); update('city', display); if (!data.name) update('name', `${data.type === 'kauf' ? 'Kaufsuche' : 'Mietsuche'} ${display}`); }; return (
update('name', v)} placeholder="z.B. „Altbau Rostock KTV“" /> { update('city', value); update('locationId', ''); update('locationSlug', ''); update('locationDisplay', ''); }} onSelect={selectLocation} /> update('radius', +e.target.value)} style={{ width: '100%', accentColor: 'var(--primary)' }} />
nur Stadt25 km50 km
); } function LocationSuggestInput({ value, selected, onInput, onSelect }) { const [suggestions, setSuggestions] = React.useState([]); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const abortRef = React.useRef(null); React.useEffect(() => { const query = String(value || '').trim(); if (selected && query === selected) return; if (query.length < 2) { setSuggestions([]); setError(null); return undefined; } const handle = setTimeout(async () => { abortRef.current?.abort?.(); const controller = new AbortController(); abortRef.current = controller; setLoading(true); setError(null); try { const response = await fetch(`/revamp-api/locations?q=${encodeURIComponent(query)}`, { headers: { Accept: 'application/json' }, signal: controller.signal, }); if (!response.ok) throw new Error('Standortsuche derzeit nicht verfügbar'); const payload = await response.json(); setSuggestions(Array.isArray(payload.locations) ? payload.locations : []); if (payload.available === false) setError(payload.message || 'Standortsuche derzeit nicht verfügbar'); } catch (err) { if (err.name !== 'AbortError') { setSuggestions([]); setError(err.message || 'Standortsuche derzeit nicht verfügbar'); } } finally { setLoading(false); } }, 250); return () => clearTimeout(handle); }, [value, selected]); return (
{selected && (
{selected}
)} {loading &&
Suche nach Standorten…
} {error &&
{error}
} {!selected && !loading && String(value || '').trim().length >= 2 && suggestions.length === 0 && !error && (
Keine Treffer gefunden.
)} {!selected && suggestions.length > 0 && (
{suggestions.map(location => { const display = location.display_name || location.name || ''; return ( ); })}
)}

Wie in der alten App wird die Kleinanzeigen-Ortssuche verwendet. Bitte einen Vorschlag übernehmen, damit PLZ/Stadtteil und Location-ID korrekt sind.

); } function BuilderStep2({ data, update }) { const isKauf = data.type === 'kauf'; return (
update('priceMin', v)} onMax={v => update('priceMax', v)} fmt={isKauf ? fmtPriceShort : v => v + ' €'} absMin={isKauf ? 50000 : 300} absMax={isKauf ? 2000000 : 4000} step={isKauf ? 25000 : 50} /> update('roomsMin', v)} onMax={v => update('roomsMax', v)} fmt={v => v + ' Zi.'} absMin={1} absMax={8} step={0.5} /> update('areaMin', v)} onMax={v => update('areaMax', v)} fmt={v => v + ' m²'} absMin={20} absMax={300} step={5} />
); } function BuilderStep3({ data, update }) { const toggle = (kw, list, listKey) => { const set = new Set(list); if (set.has(kw)) set.delete(kw); else set.add(kw); update(listKey, [...set]); }; const presets = ['altbau', 'balkon', 'stuck', 'dielen', 'aufzug', 'garten', 'einbauküche', 'saniert', 'erstbezug', 'denkmal']; const exPresets = ['erbpacht', 'sanierungsbedürftig', 'wbs', 'möbliert', 'tausch']; const [kwInput, setKwInput] = React.useState(''); const [exInput, setExInput] = React.useState(''); return (
{data.keywords.map(k => ( {k} ))}
{ if (e.key === 'Enter' && kwInput.trim()) { update('keywords', [...data.keywords, kwInput.trim().toLowerCase()]); setKwInput(''); } }} />
{presets.filter(p => !data.keywords.includes(p)).map(p => ( ))}
{data.excludeKeywords.map(k => ( – {k} ))}
{ if (e.key === 'Enter' && exInput.trim()) { update('excludeKeywords', [...data.excludeKeywords, exInput.trim().toLowerCase()]); setExInput(''); } }} />
{exPresets.filter(p => !data.excludeKeywords.includes(p)).map(p => ( ))}
); } function BuilderStep4({ data, update }) { const togglePlatform = (p) => { const set = new Set(data.platforms); if (set.has(p)) set.delete(p); else set.add(p); update('platforms', [...set]); }; return (
togglePlatform('immoscout24')} color="#1F6B3A" /> togglePlatform('kleinanzeigen')} color="#C28428" />
{data.type === 'kauf' && ( <>
update('aiEnabled', !data.aiEnabled)} className="card" style={{ padding: 16, cursor: 'pointer' }} >
Automatische KI-Analyse
Jedes neue Inserat erhält einen Profitabilitäts-Score (0–100), inkl. Stärken, Risiken & Renditeschätzung.
{data.aiEnabled && (

Verbrauch: ca. 1 KI-Token pro Inserat. Bewertungen ≥ 80 erhalten Sie als Push-Mitteilung.

)} )}
Zusammenfassung
{data.type === 'kauf' ? 'Kauf' : 'Miete'} in {data.locationDisplay || data.city || '—'} +{data.radius}km
{fmtPriceShort(data.priceMin)}–{fmtPriceShort(data.priceMax)}, {data.roomsMin}–{data.roomsMax} Zi., {data.areaMin}–{data.areaMax} m²
{data.keywords.length > 0 && <>Stichwörter: {data.keywords.join(', ')}
} Plattformen: {data.platforms.length}
); } function PlatformOption({ name, desc, active, onToggle, color }) { return (
{name}
{desc}
{active && }
); } function Toggle({ on }) { return (
); } // ─── Form primitives ─────────────────────────────────────────────────── function Label({ children, style }) { return
{children}
; } function Input({ value, onChange, placeholder, onKeyDown, type = 'text' }) { return ( onChange(e.target.value)} placeholder={placeholder} type={type} onKeyDown={onKeyDown} style={{ width: '100%', padding: '13px 14px', fontSize: 15, borderRadius: 12, background: 'var(--surface)', border: '1px solid var(--border)', fontFamily: 'var(--font-sans)', color: 'var(--ink)', outline: 'none', boxSizing: 'border-box', }} /> ); } function Pill({ children, active, onClick }) { return ( ); } function RangeRow({ min, max, onMin, onMax, fmt, absMin, absMax, step }) { return (
{fmt(min)} {fmt(max)}
onMin(Math.min(+e.target.value, max))} style={{ flex: 1, accentColor: 'var(--primary)' }} /> onMax(Math.max(+e.target.value, min))} style={{ flex: 1, accentColor: 'var(--primary)' }} />
); } // ─── SEARCH RESULTS ──────────────────────────────────────────────────── function ResultsScreen({ ctx, searchId }) { const { listings, searches, navigate, back, favs, toggleFav, deleteSearch, refreshSearch, evaluateSearch, exportSearch, busy } = ctx; const search = searches.find(s => s.id === searchId) || searches[0]; const [sort, setSort] = React.useState('score'); // score | price | newest const [view, setView] = React.useState('cards'); // cards | table const [filterOpen, setFilterOpen] = React.useState(false); const [filters, setFilters] = React.useState({ platforms: { immoscout24: true, kleinanzeigen: true }, minScore: 0, provFreeOnly: false, }); const [refreshing, setRefreshing] = React.useState(false); // Apply filters if (!search) { return (
} title="Keine Suche gefunden" subtitle="Legen Sie zuerst eine Suche an." />
); } let filtered = listings.filter(l => String(l.searchId) === String(search.id)); if (search.category === 'kauf') { if (search.priceMin) filtered = filtered.filter(l => l.price >= search.priceMin); if (search.priceMax) filtered = filtered.filter(l => l.price <= search.priceMax); } filtered = filtered.filter(l => filters.platforms[l.platform]); if (filters.minScore > 0) filtered = filtered.filter(l => Number(l.aiScore ?? 0) >= Number(filters.minScore)); if (filters.provFreeOnly) filtered = filtered.filter(l => l.provisionsfrei); filtered = [...filtered].sort((a, b) => { if (sort === 'score') return (b.aiScore || 0) - (a.aiScore || 0); if (sort === 'price') return a.price - b.price; if (sort === 'newest') return new Date(b.posted) - new Date(a.posted); return 0; }); const doRefresh = async () => { setRefreshing(true); try { await refreshSearch(search.taskId || search.id); } finally { setRefreshing(false); } }; const doEvaluate = async () => { await evaluateSearch(search.taskId || search.id); }; const doExport = () => { exportSearch(search.taskId || search.id); }; const doDelete = async () => { if (confirm(`Suche „${search.name}" wirklich löschen?`)) { await deleteSearch(search.taskId || search.id); back(); } }; return (
} /> {/* Filter / sort bar */}
setSort('score')}>KI-Score setSort('price')}>Preis ↑ setSort('newest')}>Neueste
setView('cards')}> setView('table')}>
{/* Summary */}
{filtered.length} Treffer · zuletzt aktualisiert {fmtRelative(search.lastRun)} {busy && · arbeitet...}
{search.newToday > 0 && +{search.newToday} neu}
{filtered.length === 0 ? ( } title="Keine Treffer" subtitle="Lockern Sie die Filter oder fügen Sie weitere Plattformen hinzu." cta={} /> ) : view === 'cards' ? (
{filtered.map(l => ( navigate('listing', { id: l.id })} isFav={favs.has(l.id)} onFav={toggleFav} /> ))}
) : ( )}
{/* Filter sheet */} setFilterOpen(false)} title="Filter" height="62%">
setFilters(f => ({ ...f, platforms: { ...f.platforms, immoscout24: !f.platforms.immoscout24 } }))} /> setFilters(f => ({ ...f, platforms: { ...f.platforms, kleinanzeigen: !f.platforms.kleinanzeigen } }))} />
{search.category === 'kauf' && ( <> setFilters(f => ({ ...f, minScore: +e.target.value }))} style={{ width: '100%', accentColor: 'var(--primary)' }} />
alle5080+
)}
setFilters(f => ({ ...f, provFreeOnly: !f.provFreeOnly }))} className="card" style={{ padding: 14, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
Maklerprovision ausschließen
); } function SortPill({ children, active, onClick }) { return ( ); } function ViewBtn({ active, onClick, children }) { return ( ); } function FilterToggleCard({ label, active, onClick }) { return (
{label}
); } function ResultsMoreMenu({ onExport, onEvaluate, onDelete }) { const [open, setOpen] = React.useState(false); return (
setOpen(!open)}> {open && ( <>
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 50 }} />
} onClick={() => { setOpen(false); onEvaluate(); }}>KI-Bewertung starten } onClick={() => { setOpen(false); onExport(); }}>Als CSV exportieren
} danger onClick={() => { setOpen(false); onDelete(); }}>Suche löschen
)}
); } function MenuItem({ icon, children, onClick, danger }) { return ( ); } function ResultsTable({ listings, navigate, favs, toggleFav }) { return (
{listings.map((l, i) => (
navigate('listing', { id: l.id })} style={{ display: 'flex', gap: 12, padding: 12, alignItems: 'center', cursor: 'pointer', borderBottom: i < listings.length - 1 ? '0.5px solid var(--border)' : 'none', }} >
{l.title}
{l.district} · {fmtArea(l.area)} · {l.rooms} Zi.
{fmtPriceShort(l.price)}
{l.aiScore !== null && l.aiScore !== undefined && (
= 80 ? '#2F8F5A' : l.aiScore >= 60 ? '#C28428' : '#C8331C', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, }}>{l.aiScore}
)}
))}
); } Object.assign(window, { DashboardScreen, SearchBuilderScreen, ResultsScreen, IconBtn, Section, Toggle, Label });