Many listings die or 404 within hours of being published, and several landlord pages render their stats via JS that our Playwright fetch doesn't reliably catch. In those cases the LLM correctly returns nulls — but we'd then show "2 Z · vor 30 min" and lose the m²/€/WBS info that the inberlinwohnen.de scraper had captured authoritatively. The list now coalesces: e.rooms / e.size_sqm / e.rent_total or rent_cold / e.wbs_required take precedence; when null we fall back to f.rooms, f.size, f.total_rent, f.wbs respectively. Boolean wbs_required uses `is sameas` so an explicit `false` (no-WBS) from the LLM is preserved instead of being treated as missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
262 lines
14 KiB
HTML
262 lines
14 KiB
HTML
{# Renders the full Wohnungen body; used both by the full page and by HTMX poll. #}
|
|
<div id="wohnungen-body"
|
|
class="space-y-5"
|
|
hx-get="/partials/wohnungen"
|
|
hx-trigger="every {{ poll_interval }}s"
|
|
hx-swap="outerHTML">
|
|
|
|
<!-- Reihe 1: Info-Kacheln Alarm + Filter -->
|
|
<section class="grid grid-cols-2 gap-3">
|
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/benachrichtigungen">
|
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
|
</div>
|
|
</a>
|
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Filter</div>
|
|
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
|
|
</a>
|
|
</section>
|
|
|
|
<!-- Reihe 2: Schalter Automatisch bewerben + Final absenden -->
|
|
<section class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<form class="card p-4 flex items-center justify-between gap-3">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<div class="text-sm font-medium">Automatisch bewerben</div>
|
|
<label class="switch warn">
|
|
<input type="checkbox" name="value" value="on"
|
|
hx-post="/actions/auto-apply"
|
|
hx-trigger="change"
|
|
hx-include="closest form"
|
|
hx-target="#wohnungen-body"
|
|
hx-swap="outerHTML"
|
|
{% if not auto_apply_enabled %}hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}
|
|
{% if auto_apply_enabled %}checked{% endif %}>
|
|
<span class="switch-visual"></span>
|
|
</label>
|
|
</form>
|
|
|
|
<form class="card p-4 flex items-center justify-between gap-3">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<div class="text-sm font-medium">Final absenden</div>
|
|
<label class="switch warn">
|
|
<input type="checkbox" name="value" value="on"
|
|
hx-post="/actions/submit-forms"
|
|
hx-trigger="change"
|
|
hx-include="closest form"
|
|
hx-target="#wohnungen-body"
|
|
hx-swap="outerHTML"
|
|
{% if not submit_forms %}hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"{% endif %}
|
|
{% if submit_forms %}checked{% endif %}>
|
|
<span class="switch-visual"></span>
|
|
</label>
|
|
</form>
|
|
</section>
|
|
|
|
{% if not apply_allowed %}
|
|
<div class="card p-3 text-sm">
|
|
<span class="chip chip-bad">Bewerbungs-Dienst nicht erreichbar</span>
|
|
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if circuit_open %}
|
|
<div class="card p-3 text-sm flex items-center justify-between">
|
|
<div>
|
|
<span class="chip chip-bad">Automatik pausiert</span>
|
|
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Folge</span>
|
|
</div>
|
|
<form method="post" action="/actions/reset-circuit"
|
|
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<button class="btn btn-ghost text-xs" type="submit">Zurücksetzen</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Header + View-Toggle (Liste/Karte) -->
|
|
<section class="flex items-center justify-between gap-4 flex-wrap">
|
|
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
|
<div class="flex items-center gap-3 text-xs text-slate-500">
|
|
<span>{{ flats|length }} gefunden</span>
|
|
{% if next_scrape_utc %}
|
|
<span class="sep">·</span>
|
|
<span>nächste Aktualisierung <span class="countdown" data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
|
{% endif %}
|
|
{% if is_admin and (enrichment_counts.pending or enrichment_counts.failed) %}
|
|
<span class="sep">·</span>
|
|
<form method="post" action="/actions/enrich-all"
|
|
hx-post="/actions/enrich-all" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<button class="btn btn-ghost text-xs" type="submit"
|
|
hx-confirm="Altbestand jetzt durch Haiku nachträglich anreichern? Kann einige Minuten dauern.">
|
|
Anreichern ({{ enrichment_counts.pending + enrichment_counts.failed }})
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
<div class="view-toggle ml-2">
|
|
<label>
|
|
<input type="radio" name="view_mode" id="v_list" value="list" checked>
|
|
Liste
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="view_mode" id="v_map" value="map">
|
|
Karte
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Karte (Leaflet-Container bleibt über HTMX-Swaps hinweg erhalten) -->
|
|
<section class="view-map">
|
|
<div class="card p-3">
|
|
<div id="flats-map" hx-preserve="true"></div>
|
|
</div>
|
|
</section>
|
|
<script id="flats-map-data" type="application/json" data-csrf="{{ csrf }}">{{ map_points | tojson }}</script>
|
|
|
|
<!-- Liste -->
|
|
<section class="view-list card">
|
|
<div>
|
|
{% for item in flats %}
|
|
{% set f = item.row %}
|
|
<div class="flat-row">
|
|
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
|
{{ f.address or f.link }}
|
|
</a>
|
|
{% if item.last and item.last.finished_at is none %}
|
|
<span class="chip chip-warn">läuft…</span>
|
|
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
|
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
|
{% endif %}
|
|
{% if f.enrichment_status == 'pending' %}<span class="chip">analysiert…</span>
|
|
{% elif f.enrichment_status == 'failed' %}<span class="chip chip-warn" title="Detail-Analyse fehlgeschlagen">?</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="text-xs text-slate-500 mt-0.5">
|
|
{% if f.enrichment_status == 'pending' %}
|
|
Infos werden abgerufen…
|
|
· <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
|
{% elif f.enrichment_status == 'failed' %}
|
|
{% set err = (item.enrichment or {}).get('_error') or 'unbekannt' %}
|
|
<span title="{{ err }}">Fehler beim Abrufen der Infos</span>
|
|
{% if is_admin %}
|
|
<form method="post" action="/actions/enrich-flat" class="inline"
|
|
hx-post="/actions/enrich-flat" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button type="submit" class="underline text-slate-600 hover:text-slate-900 ml-1">erneut versuchen</button>
|
|
</form>
|
|
{% endif %}
|
|
· <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
|
{% else %}
|
|
{# LLM first, scraper as fallback. The scraper data
|
|
from inberlinwohnen.de is reliable; we only
|
|
replace it when the LLM has a concrete value. #}
|
|
{% set e = item.enrichment or {} %}
|
|
{% set rooms = e.rooms if e.rooms is not none else f.rooms %}
|
|
{% set size = e.size_sqm if e.size_sqm is not none else f.size %}
|
|
{% set rent = e.rent_total or e.rent_cold or f.total_rent %}
|
|
{% if e.wbs_required is sameas true %}
|
|
{% set wbs_label = 'WBS: ' ~ (e.wbs_type or 'erforderlich') %}
|
|
{% elif e.wbs_required is sameas false %}
|
|
{% set wbs_label = 'ohne WBS' %}
|
|
{% elif f.wbs == 'erforderlich' %}
|
|
{% set wbs_label = 'WBS: erforderlich' %}
|
|
{% elif f.wbs == 'nicht erforderlich' %}
|
|
{% set wbs_label = 'ohne WBS' %}
|
|
{% else %}
|
|
{% set wbs_label = '' %}
|
|
{% endif %}
|
|
{% set parts = [] %}
|
|
{% if rooms %}{% set _ = parts.append('%g Z'|format(rooms)) %}{% endif %}
|
|
{% if size %}{% set _ = parts.append('%.0f m²'|format(size)) %}{% endif %}
|
|
{% if rent %}{% set _ = parts.append('%.0f €'|format(rent)) %}{% endif %}
|
|
{% if wbs_label %}{% set _ = parts.append(wbs_label) %}{% endif %}
|
|
{{ parts|join(' · ') }}{% if parts %} · {% endif %}<span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
|
{% set is_running = item.last and item.last.finished_at is none %}
|
|
<form method="post" action="/actions/apply"
|
|
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button class="btn btn-primary text-sm" type="submit"
|
|
{% if is_running %}disabled{% endif %}
|
|
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
|
|
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
<form method="post" action="/actions/reject"
|
|
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button class="btn btn-ghost text-sm" type="submit"
|
|
hx-confirm="Ablehnen und aus der Liste entfernen?">
|
|
Ablehnen
|
|
</button>
|
|
</form>
|
|
<button type="button" class="flat-expand-btn" aria-label="Details"
|
|
data-flat-id="{{ f.id }}">
|
|
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 8 10 13 15 8"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flat-detail" id="flat-detail-{{ f.id|flat_slug }}" hx-preserve="true"></div>
|
|
</div>
|
|
{% else %}
|
|
<div class="px-4 py-8 text-center text-slate-500">
|
|
{% if not has_filters %}
|
|
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
|
|
{% else %}
|
|
Aktuell keine Wohnung, die alle Filter erfüllt.
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
|
|
|
|
{% if rejected_flats %}
|
|
<section class="card">
|
|
<details class="group">
|
|
<summary class="px-4 py-3 text-sm font-medium flex items-center justify-between cursor-pointer">
|
|
<span>Abgelehnte Wohnungen</span>
|
|
<span class="text-xs text-slate-500">{{ rejected_flats|length }}</span>
|
|
</summary>
|
|
<div class="divide-y divide-soft border-t border-soft">
|
|
{% for f in rejected_flats %}
|
|
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
|
{{ f.address or f.link }}
|
|
</a>
|
|
<div class="text-xs text-slate-500 mt-0.5">
|
|
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
|
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
|
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
|
· abgelehnt <span data-rel-utc="{{ f.rejected_at|iso_utc }}" title="{{ f.rejected_at|de_dt }}">…</span>
|
|
</div>
|
|
</div>
|
|
<form method="post" action="/actions/unreject"
|
|
hx-post="/actions/unreject" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button class="btn btn-ghost text-sm" type="submit">Wiederherstellen</button>
|
|
</form>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</details>
|
|
</section>
|
|
{% endif %}
|
|
|
|
</div>
|