lazyflat/web/templates/_wohnungen_body.html
EiSiMo a8f698bf5e enrichment: capture failure cause + admin retry button
Each enrichment failure now records {"_error": "...", "_step": "..."} into
enrichment_json, mirrors the message into the errors log (visible in
/logs/protokoll), and the list shows the cause as a tooltip on the
"Fehler beim Abrufen der Infos" text. Admins also get a "erneut versuchen"
link per failed row that re-queues just that flat (POST /actions/enrich-flat).

The pipeline raises a typed EnrichmentError per step (fetch / llm / crash)
so future failure modes don't get swallowed as a silent "failed".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:05:39 +02:00

250 lines
13 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 %}
{% set e = item.enrichment or {} %}
{% set parts = [] %}
{% if e.rooms %}{% set _ = parts.append('%g Z'|format(e.rooms)) %}{% endif %}
{% if e.size_sqm %}{% set _ = parts.append('%.0f m²'|format(e.size_sqm)) %}{% endif %}
{% set rent = e.rent_total or e.rent_cold %}
{% if rent %}{% set _ = parts.append('%.0f €'|format(rent)) %}{% endif %}
{% if e.wbs_required is true %}
{% set _ = parts.append('WBS: ' ~ (e.wbs_type or 'erforderlich')) %}
{% elif e.wbs_required is false %}
{% set _ = parts.append('ohne WBS') %}
{% 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>