1. New /admin route with sub-tabs (Protokoll, Benutzer) for admins. Top nav: "Protokoll" dropped, "Admin" added right of Einstellungen. /logs and /einstellungen/benutzer issue 301 redirects to the new paths. Benutzer is no longer part of Einstellungen sub-nav. 2. User_filters.max_age_hours (migration v6) — new dropdown (1–10 h / beliebig) under Einstellungen → Filter; Wohnungen list drops flats older than the cutoff by discovered_at. 3. Header shows "aktualisiert vor X s" instead of a countdown. Template emits data-counter-up-utc with last_alert_heartbeat; app.js ticks up each second. When a scrape runs, the heartbeat updates and the HTMX swap resets the counter naturally. 4. Chevron state synced after HTMX swaps: panes preserved via hx-preserve keep the user's open/closed state, and the sibling button's .open class is re-applied by syncFlatExpandState() on afterSwap — previously a scroll-triggered poll would flip the chevron back to closed while the pane stayed open. 5. "Final absenden" footer removed from the profile page (functionality is unchanged, the switch still sits atop Wohnungen). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
11 KiB
HTML
226 lines
11 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 last_scrape_utc %}
|
|
<span class="sep">·</span>
|
|
<span>aktualisiert <span class="countdown" data-counter-up-utc="{{ last_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="Bilder für ausstehende Wohnungen nachladen? Kann einige Minuten dauern.">
|
|
Bilder nachladen ({{ 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 %}
|
|
</div>
|
|
<div class="text-xs text-slate-500 mt-0.5">
|
|
{% set parts = [] %}
|
|
{% if f.rooms %}{% set _ = parts.append('%g Z'|format(f.rooms)) %}{% endif %}
|
|
{% if f.size %}{% set _ = parts.append('%.0f m²'|format(f.size)) %}{% endif %}
|
|
{% if f.total_rent %}{% set _ = parts.append('%.0f €'|format(f.total_rent)) %}{% endif %}
|
|
{% if f.wbs == 'erforderlich' %}{% set _ = parts.append('WBS: erforderlich') %}
|
|
{% elif f.wbs == 'nicht erforderlich' %}{% 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>
|
|
</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>
|