multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics

* DB: users + user_profiles/filters/notifications/preferences; applications gets
  user_id + forensics_json + profile_snapshot_json; new errors table
  with 14d retention; schema versioning via MIGRATIONS list
* auth: password hashes in DB (argon2); env vars seed first admin; per-user
  sessions; CSRF bound to user id
* apply: personal info/WBS moved out of env into the request body; providers
  take an ApplyContext with Profile + submit_forms; full Playwright recorder
  (step log, console, page errors, network, screenshots, final HTML)
* web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen);
  settings sub-tabs profil/filter/benachrichtigungen/account/benutzer;
  per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red
  auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen
  and fehler; retention background thread

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 10:52:41 +02:00
parent e663386a19
commit c630b500ef
36 changed files with 2763 additions and 1113 deletions

View file

@ -1,176 +0,0 @@
<section class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">alert</div>
<div class="mt-2 text-lg">
{% if last_alert_heartbeat %}
<span class="chip chip-ok">live</span>
{% else %}
<span class="chip chip-warn">kein Heartbeat</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-1">letzter Heartbeat: {{ last_alert_heartbeat or "—" }}</div>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">apply</div>
<div class="mt-2 text-lg">
{% if apply_reachable %}
<span class="chip chip-ok">reachable</span>
{% else %}
<span class="chip chip-bad">down</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-1">
{% if circuit_open %}
<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}
{{ apply_failures }} recent failure(s)
{% else %}
healthy
{% endif %}
</div>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">Modus</div>
<div class="mt-2 text-lg">
{% if mode == "auto" %}
<span class="chip chip-warn">full-auto</span>
{% else %}
<span class="chip chip-info">manuell</span>
{% endif %}
</div>
<form method="post" action="/actions/mode" class="mt-2 flex gap-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="mode" value="{% if mode == 'auto' %}manual{% else %}auto{% endif %}">
<button class="btn btn-ghost text-sm" type="submit">
→ zu {% if mode == 'auto' %}manuell{% else %}full-auto{% endif %}
</button>
</form>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">KillSwitch</div>
<div class="mt-2 text-lg">
{% if kill_switch %}
<span class="chip chip-bad">apply gestoppt</span>
{% else %}
<span class="chip chip-ok">aktiv</span>
{% endif %}
</div>
<form method="post" action="/actions/kill-switch" class="mt-2 flex gap-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
{% if kill_switch %}Freigeben{% else %}Alles stoppen{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit" class="mt-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4 border-red-900/50">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
</div>
{% endif %}
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Wohnungen</h2>
<span class="text-xs text-slate-400">{{ flats|length }} zuletzt gesehen</span>
</div>
<div class="divide-y divide-soft">
{% for flat in 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">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate hover:underline" href="{{ flat.link }}" target="_blank" rel="noopener noreferrer">
{{ flat.address or flat.link }}
</a>
{% if flat.matched_criteria %}
<span class="chip chip-ok">match</span>
{% else %}
<span class="chip chip-info">info</span>
{% endif %}
{% if flat.last_application_success == 1 %}
<span class="chip chip-ok">beworben</span>
{% elif flat.last_application_success == 0 %}
<span class="chip chip-bad">apply fehlgeschlagen</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-0.5">
{% if flat.rooms %}{{ "%.1f"|format(flat.rooms) }} Z{% endif %}
{% if flat.size %} · {{ "%.0f"|format(flat.size) }} m²{% endif %}
{% if flat.total_rent %} · {{ "%.0f"|format(flat.total_rent) }} €{% endif %}
{% if flat.sqm_price %} ({{ "%.2f"|format(flat.sqm_price) }} €/m²){% endif %}
{% if flat.connectivity_morning_time %} · {{ "%.0f"|format(flat.connectivity_morning_time) }} min morgens{% endif %}
· entdeckt {{ flat.discovered_at }}
</div>
{% if flat.last_application_message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ flat.last_application_message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not flat.last_application_success %}
<form method="post" action="/actions/apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ flat.id }}">
<button class="btn btn-primary text-sm" type="submit"
onclick="return confirm('Bewerbung für {{ (flat.address or flat.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen gesehen.</div>
{% endfor %}
</div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card">
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Letzte Bewerbungen</h2></div>
<div class="divide-y divide-soft">
{% for a in applications %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span>
<span class="text-slate-400 text-xs">{{ a.started_at }}</span>
</div>
<div class="mt-1 truncate">{{ a.address or a.url }}</div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5">{{ a.message }}</div>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Bewerbungen bisher.</div>
{% endfor %}
</div>
</div>
<div class="card">
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Audit-Log</h2></div>
<div class="divide-y divide-soft">
{% for e in audit %}
<div class="px-4 py-2 text-xs font-mono">
<span class="text-slate-500">{{ e.timestamp }}</span>
<span class="text-slate-400">{{ e.actor }}</span>
<span class="text-slate-700">{{ e.action }}</span>
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">leer</div>
{% endfor %}
</div>
</div>
</section>

View file

@ -0,0 +1,31 @@
{#
Shared layout: top bar with brand + user + logout, tab nav, body container.
Used by every authenticated view via `{% extends "_layout.html" %}`.
#}
{% extends "base.html" %}
{% block body %}
<header class="border-b border-soft bg-white/70 backdrop-blur sticky top-0 z-10">
<div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="brand-dot"></div>
<h1 class="text-xl font-semibold">lazyflat</h1>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">admin</span>{% endif %}</span>
<form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
</form>
</div>
</div>
<nav class="max-w-6xl mx-auto px-6 flex border-b border-soft -mb-px">
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
<a class="tab {% if active_tab=='fehler' %}active{% endif %}" href="/fehler">Fehler</a>
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
</nav>
</header>
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
{% block content %}{% endblock %}
</main>
{% endblock %}

View file

@ -0,0 +1,27 @@
<h2 class="font-semibold mb-4">Account</h2>
<div class="text-sm text-slate-600 mb-4">
Angemeldet als <b>{{ user.username }}</b>{% if is_admin %} (Administrator){% endif %}.
</div>
{% if request.query_params.get('ok') %}<div class="chip chip-ok mb-4">Passwort geändert.</div>{% endif %}
{% if request.query_params.get('err') == 'wrongold' %}<div class="chip chip-bad mb-4">Altes Passwort falsch.</div>{% endif %}
{% if request.query_params.get('err') == 'mismatch' %}<div class="chip chip-bad mb-4">Neue Passwörter stimmen nicht überein.</div>{% endif %}
{% if request.query_params.get('err') == 'tooshort' %}<div class="chip chip-bad mb-4">Passwort zu kurz (min. 10 Zeichen).</div>{% endif %}
<form method="post" action="/actions/account/password" class="space-y-3 max-w-md">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Altes Passwort</label>
<input class="input" type="password" name="old_password" autocomplete="current-password" required>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Neues Passwort (≥ 10 Zeichen)</label>
<input class="input" type="password" name="new_password" autocomplete="new-password" required>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Neues Passwort wiederholen</label>
<input class="input" type="password" name="new_password_repeat" autocomplete="new-password" required>
</div>
<button class="btn btn-primary" type="submit">Passwort ändern</button>
</form>

View file

@ -0,0 +1,40 @@
<h2 class="font-semibold mb-2">Filter</h2>
<p class="text-sm text-slate-600 mb-4">
Die Filter bestimmen, bei welchen Wohnungen du eine Benachrichtigung bekommst und worauf Auto-Bewerben greift.
Leer lassen = kein Limit.
</p>
<form method="post" action="/actions/filters" class="grid grid-cols-2 md:grid-cols-3 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
<select class="input" name="wbs_required">
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
</select>
</div>
<div class="col-span-2 md:col-span-3">
<button class="btn btn-primary" type="submit">Filter speichern</button>
</div>
</form>

View file

@ -0,0 +1,51 @@
<h2 class="font-semibold mb-2">Benachrichtigungen</h2>
<p class="text-sm text-slate-600 mb-4">
Wähle einen Kanal und entscheide, welche Events dich erreichen sollen.
</p>
<form method="post" action="/actions/notifications" class="space-y-4 max-w-xl">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Kanal</label>
<select class="input" name="channel">
<option value="ui" {% if notifications.channel == 'ui' %}selected{% endif %}>Nur im Dashboard (kein Push)</option>
<option value="telegram" {% if notifications.channel == 'telegram' %}selected{% endif %}>Telegram</option>
<option value="email" {% if notifications.channel == 'email' %}selected{% endif %}>E-Mail</option>
</select>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Telegram Bot-Token</label>
<input class="input" name="telegram_bot_token" value="{{ notifications.telegram_bot_token }}"
placeholder="123456:ABC...">
<p class="text-xs text-slate-500 mt-1">Bot bei @BotFather anlegen, Token hier eintragen.</p>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Telegram Chat-ID</label>
<input class="input" name="telegram_chat_id" value="{{ notifications.telegram_chat_id }}" placeholder="987654321">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">E-Mail Adresse</label>
<input class="input" type="email" name="email_address" value="{{ notifications.email_address }}"
placeholder="du@example.com">
</div>
<div class="border-t border-soft pt-4 space-y-2">
<label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_match" {% if notifications.notify_on_match %}checked{% endif %}>
<span>Bei passender Wohnung</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_apply_success" {% if notifications.notify_on_apply_success %}checked{% endif %}>
<span>Bei erfolgreicher Bewerbung</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_apply_fail" {% if notifications.notify_on_apply_fail %}checked{% endif %}>
<span>Bei fehlgeschlagener Bewerbung</span>
</label>
</div>
<button class="btn btn-primary" type="submit">Speichern</button>
</form>

View file

@ -0,0 +1,123 @@
<h2 class="font-semibold mb-4">Bewerbungsdaten</h2>
<p class="text-sm text-slate-600 mb-4">
Diese Angaben werden beim Bewerben an die jeweilige Website gesendet.
<span class="chip chip-warn">sensibel</span> — werden nur in der DB gespeichert und pro Bewerbung als Snapshot protokolliert.
</p>
<form method="post" action="/actions/profile" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Anrede</label>
<select class="input" name="salutation">
{% for s in ['Herr', 'Frau', 'Divers'] %}
<option value="{{ s }}" {% if profile.salutation == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div></div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Vorname</label>
<input class="input" name="firstname" value="{{ profile.firstname }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Nachname</label>
<input class="input" name="lastname" value="{{ profile.lastname }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">E-Mail</label>
<input class="input" type="email" name="email" value="{{ profile.email }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Telefon</label>
<input class="input" name="telephone" value="{{ profile.telephone }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Straße</label>
<input class="input" name="street" value="{{ profile.street }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Hausnummer</label>
<input class="input" name="house_number" value="{{ profile.house_number }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">PLZ</label>
<input class="input" name="postcode" value="{{ profile.postcode }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Stadt</label>
<input class="input" name="city" value="{{ profile.city }}">
</div>
<div class="col-span-1 md:col-span-2 mt-4 border-t border-soft pt-4">
<h3 class="font-semibold mb-2">WBS</h3>
</div>
<label class="col-span-1 md:col-span-2 inline-flex items-center gap-2">
<input type="checkbox" name="is_possessing_wbs" {% if profile.is_possessing_wbs %}checked{% endif %}>
<span class="text-sm">WBS vorhanden</span>
</label>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">WBS-Typ</label>
<input class="input" name="wbs_type" value="{{ profile.wbs_type }}" placeholder="z.B. 180">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">gültig bis</label>
<input class="input" type="date" name="wbs_valid_till" value="{{ profile.wbs_valid_till }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Räume</label>
<input class="input" type="number" name="wbs_rooms" value="{{ profile.wbs_rooms }}" min="0">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Erwachsene</label>
<input class="input" type="number" name="wbs_adults" value="{{ profile.wbs_adults }}" min="0">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Kinder</label>
<input class="input" type="number" name="wbs_children" value="{{ profile.wbs_children }}" min="0">
</div>
<label class="inline-flex items-center gap-2 mt-6">
<input type="checkbox" name="is_prio_wbs" {% if profile.is_prio_wbs %}checked{% endif %}>
<span class="text-sm">Prio-WBS (besonderer Wohnbedarf)</span>
</label>
<div class="col-span-1 md:col-span-2 mt-4 border-t border-soft pt-4">
<h3 class="font-semibold mb-2">Immomio-Login (optional)</h3>
<p class="text-xs text-slate-500 mb-2">
Wird von Anbietern benötigt, die über immomio/tenant vermitteln (z.B. gesobau.de).
</p>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Immomio-Email</label>
<input class="input" type="email" name="immomio_email" value="{{ profile.immomio_email }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Immomio-Passwort</label>
<input class="input" type="password" name="immomio_password" value="{{ profile.immomio_password }}" placeholder="(unverändert lassen = leer)">
</div>
<div class="col-span-1 md:col-span-2">
<button class="btn btn-primary" type="submit">Speichern</button>
</div>
</form>
<hr class="my-6 border-soft">
<h3 class="font-semibold mb-2">Formulare wirklich absenden?</h3>
<p class="text-sm text-slate-600 mb-3">
<span class="chip chip-warn">experimentell</span>
Im Dry-Run-Modus füllt apply das Formular aus und stoppt vor „Senden". Nur einschalten, wenn du jeden Anbieter einmal im Dry-Run verifiziert hast.
</p>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="on">
<button class="btn btn-ghost text-sm" type="submit">Echt senden einschalten</button>
</form>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center ml-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="off">
<button class="btn btn-ghost text-sm" type="submit">Dry-Run</button>
</form>

View file

@ -0,0 +1,50 @@
<h2 class="font-semibold mb-4">Benutzer verwalten</h2>
{% if request.query_params.get('ok') %}<div class="chip chip-ok mb-4">Benutzer angelegt.</div>{% endif %}
{% if request.query_params.get('err') == 'exists' %}<div class="chip chip-bad mb-4">Benutzername existiert bereits.</div>{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="font-semibold mb-2">Neuen Benutzer anlegen</h3>
<form method="post" action="/actions/users/create" class="space-y-3 max-w-md">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Benutzername</label>
<input class="input" name="username" required>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Passwort (≥ 10 Zeichen)</label>
<input class="input" type="password" name="password" required>
</div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_admin">
<span>Admin-Rechte</span>
</label>
<button class="btn btn-primary" type="submit">Anlegen</button>
</form>
</div>
<div>
<h3 class="font-semibold mb-2">Alle Benutzer</h3>
<div class="card divide-y divide-soft">
{% for u in users %}
<div class="px-3 py-2 flex items-center gap-2 text-sm">
<span class="flex-1">{{ u.username }}</span>
{% if u.is_admin %}<span class="chip chip-info">admin</span>{% endif %}
{% if u.disabled %}<span class="chip chip-bad">deaktiviert</span>
{% else %}<span class="chip chip-ok">aktiv</span>{% endif %}
{% if u.id != user.id %}
<form method="post" action="/actions/users/disable">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="target_id" value="{{ u.id }}">
<input type="hidden" name="value" value="{% if u.disabled %}off{% else %}on{% endif %}">
<button class="btn btn-ghost text-xs" type="submit">
{% if u.disabled %}aktivieren{% else %}deaktivieren{% endif %}
</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>

View file

@ -9,19 +9,13 @@
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
:root {
--bg-from: #e4f0fb;
--bg-to: #f7fbfe;
--surface: #ffffff;
--border: #d8e6f3;
--text: #10253f;
--muted: #667d98;
--primary: #2f8ae0;
--primary-hover: #1f74c8;
--danger: #e05a6a;
--danger-hover: #c44a59;
--ghost: #eaf2fb;
--ghost-hover: #d5e5f4;
--accent: #fbd76b;
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
--surface: #ffffff; --border: #d8e6f3;
--text: #10253f; --muted: #667d98;
--primary: #2f8ae0; --primary-hover: #1f74c8;
--danger: #e05a6a; --danger-hover: #c44a59;
--ghost: #eaf2fb; --ghost-hover: #d5e5f4;
--accent: #fbd76b;
}
html { color-scheme: light; }
body {
@ -30,33 +24,24 @@
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04);
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04); }
.border-soft { border-color: var(--border) !important; }
.divide-soft > :not([hidden]) ~ :not([hidden]) { border-color: var(--border) !important; }
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500; transition: background 0.15s, box-shadow 0.15s, transform 0.05s; }
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47, 138, 224, 0.25); }
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: var(--danger-hover); }
.btn-ghost { background: var(--ghost); color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--ghost-hover); }
.input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.55rem 0.8rem;
width: 100%;
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47, 138, 224, 0.18); }
.chip { padding: 0.2rem 0.7rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; display: inline-block; }
.input { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 0.55rem 0.8rem; width: 100%; color: var(--text);
transition: border-color .15s, box-shadow .15s; }
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); }
.chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; }
.chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; }
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
@ -68,9 +53,27 @@
}
a { color: var(--primary); }
a:hover { text-decoration: underline; }
/* tab nav */
.tab { padding: 0.7rem 0.2rem; color: var(--muted); border-bottom: 2px solid transparent;
margin-right: 1.5rem; font-weight: 500; }
.tab.active { color: var(--text); border-color: var(--primary); }
.tab:hover { color: var(--text); text-decoration: none; }
/* auto-apply hot button */
.btn-hot { background: linear-gradient(135deg, #ff7a85 0%, #e14a56 100%); color: white;
box-shadow: 0 2px 6px rgba(225, 74, 86, 0.35); font-weight: 600; }
.btn-hot:hover { filter: brightness(1.05); }
.btn-hot.off { background: linear-gradient(135deg, #cfd9e6 0%, #99abc2 100%);
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.15); }
/* forensic JSON tree */
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
details > summary { cursor: pointer; user-select: none; }
details > summary::marker { color: var(--muted); }
</style>
</head>
<body class="min-h-screen">
{% block body %}{% endblock %}
{% block body %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,113 @@
{% extends "_layout.html" %}
{% block title %}Bewerbung #{{ application.id }} — lazyflat{% endblock %}
{% block content %}
<a href="/bewerbungen" class="text-sm">← zurück zu den Bewerbungen</a>
<section class="card p-5">
<div class="flex items-center gap-2 flex-wrap mb-3">
<h2 class="font-semibold text-lg">Bewerbung #{{ application.id }}</h2>
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ application.triggered_by }}</span>
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
</div>
<div class="text-sm text-slate-600 space-y-1">
<div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div>
<div><span class="text-slate-500">gestartet:</span> {{ application.started_at }}</div>
<div><span class="text-slate-500">beendet:</span> {{ application.finished_at or "—" }}</div>
{% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %}
</div>
</section>
<section class="card p-5">
<details>
<summary class="font-semibold">Profil-Snapshot zum Bewerbungszeitpunkt</summary>
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ profile_snapshot | tojson(indent=2) }}</pre>
</details>
</section>
{% if forensics %}
<section class="card p-5 space-y-4">
<h3 class="font-semibold">Forensik (für KI-Debug)</h3>
<details open>
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>
<div class="mono mt-2 space-y-0.5">
{% for s in forensics.steps %}
<div class="{% if s.status != 'ok' %}text-[#b8404e]{% else %}text-slate-700{% endif %}">
[{{ "%.2f"|format(s.ts) }}s] {{ s.step }} {% if s.status != 'ok' %}({{ s.status }}){% endif %}
{% if s.detail %}— {{ s.detail }}{% endif %}
</div>
{% endfor %}
</div>
</details>
{% if forensics.screenshots %}
<details>
<summary class="font-medium">Screenshots ({{ forensics.screenshots|length }})</summary>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
{% for s in forensics.screenshots %}
<div class="border border-soft rounded-lg p-2">
<div class="text-xs text-slate-500 mb-1">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.console %}
<details>
<summary class="font-medium">Browser-Konsole ({{ forensics.console|length }})</summary>
<div class="mono mt-2 space-y-0.5">
{% for c in forensics.console %}
<div class="text-slate-700">[{{ "%.2f"|format(c.ts) }}s] [{{ c.type }}] {{ c.text }}</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.errors %}
<details open>
<summary class="font-medium text-[#b8404e]">Browser-Errors ({{ forensics.errors|length }})</summary>
<div class="mono mt-2 space-y-0.5 text-[#b8404e]">
{% for e in forensics.errors %}
<div>[{{ "%.2f"|format(e.ts) }}s] {{ e.message }}</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.network %}
<details>
<summary class="font-medium">Netzwerk ({{ forensics.network|length }})</summary>
<div class="mono mt-2 space-y-0.5">
{% for n in forensics.network %}
<div class="text-slate-700 break-all">
[{{ "%.2f"|format(n.ts) }}s]
{% if n.kind == 'response' %}← {{ n.status }} {{ n.url }}
{% if n.body_snippet %}<div class="pl-4 text-slate-500">{{ n.body_snippet[:500] }}</div>{% endif %}
{% else %}→ {{ n.method }} {{ n.url }} [{{ n.resource_type }}]
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.final_html %}
<details>
<summary class="font-medium">Page HTML ({{ forensics.final_html|length }} B)</summary>
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft max-h-96 overflow-auto">{{ forensics.final_html }}</pre>
</details>
{% endif %}
</section>
{% elif application.finished_at %}
<section class="card p-5 text-sm text-slate-500">
Forensik für diese Bewerbung ist nicht mehr verfügbar (älter als Retention-Zeitraum).
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "_layout.html" %}
{% block title %}Bewerbungen — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Meine Bewerbungen</h2>
<span class="text-xs text-slate-500">{{ applications|length }}</span>
</div>
<div class="divide-y divide-soft">
{% for a in applications %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2 flex-wrap">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span>
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto">{{ a.started_at }}</span>
</div>
<div class="mt-1 truncate">
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
</div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
{% endfor %}
</div>
</section>
{% endblock %}

View file

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}lazyflat dashboard{% endblock %}
{% block body %}
<header class="border-b border-soft bg-white/70 backdrop-blur">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="brand-dot"></div>
<h1 class="text-xl font-semibold">lazyflat</h1>
</div>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-500">{{ user }}</span>
<form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
</form>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6"
hx-get="/partials/dashboard" hx-trigger="every 15s" hx-target="#dashboard-body" hx-swap="innerHTML">
<div id="dashboard-body">
{% include "_dashboard_body.html" %}
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "_layout.html" %}
{% block title %}Einstellungen — lazyflat{% endblock %}
{% block content %}
<section class="card">
<nav class="flex flex-wrap border-b border-soft px-4">
{% set sections = [('profil','Profil'),('filter','Filter'),('benachrichtigungen','Benachrichtigungen'),('account','Account')] %}
{% if is_admin %}{% set sections = sections + [('benutzer','Benutzer')] %}{% endif %}
{% for key, label in sections %}
<a href="/einstellungen/{{ key }}"
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
{% endfor %}
</nav>
<div class="p-5">
{% if section == 'profil' %}{% include "_settings_profil.html" %}
{% elif section == 'filter' %}{% include "_settings_filter.html" %}
{% elif section == 'benachrichtigungen' %}{% include "_settings_notifications.html" %}
{% elif section == 'account' %}{% include "_settings_account.html" %}
{% elif section == 'benutzer' %}{% include "_settings_users.html" %}
{% endif %}
</div>
</section>
{% endblock %}

29
web/templates/fehler.html Normal file
View file

@ -0,0 +1,29 @@
{% extends "_layout.html" %}
{% block title %}Fehler — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
<h2 class="font-semibold">Fehler {% if is_admin %}<span class="chip chip-info ml-2">inkl. globaler</span>{% endif %}</h2>
<span class="text-xs text-slate-500">{{ errors|length }}</span>
</div>
<div class="divide-y divide-soft">
{% for e in errors %}
<a class="block px-4 py-3 hover:bg-[#f6fafd]" href="/fehler/{{ e.id }}">
<div class="flex items-center gap-2 flex-wrap">
<span class="chip chip-bad">{{ e.kind }}</span>
<span class="chip chip-info">{{ e.source }}</span>
{% if e.application_id %}<span class="chip chip-info">#{{ e.application_id }}</span>{% endif %}
<span class="text-xs text-slate-500 ml-auto">{{ e.timestamp }}</span>
</div>
<div class="text-sm mt-1 truncate">{{ e.summary or "(kein Text)" }}</div>
</a>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Fehler — läuft rund.</div>
{% endfor %}
</div>
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
Fehler werden 14 Tage aufbewahrt. Bei fehlgeschlagenen Bewerbungen enthält die Detailseite Screenshots,
Step-Log, Browser-Konsole + Netzwerk-Trace für KI-gestützte Fehleranalyse.
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "_layout.html" %}
{% block title %}Fehler #{{ error.id }} — lazyflat{% endblock %}
{% block content %}
<a href="/fehler" class="text-sm">← zurück zu den Fehlern</a>
<section class="card p-5 space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="font-semibold text-lg">Fehler #{{ error.id }}</h2>
<span class="chip chip-bad">{{ error.kind }}</span>
<span class="chip chip-info">{{ error.source }}</span>
</div>
<div class="text-sm text-slate-600">
{{ error.timestamp }} · {{ error.summary or "(kein Text)" }}
</div>
{% if context %}
<details class="mt-2">
<summary class="font-medium">Kontext</summary>
<pre class="mono whitespace-pre-wrap break-all mt-2 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ context | tojson(indent=2) }}</pre>
</details>
{% endif %}
</section>
{% if application %}
<section class="card p-5">
<h3 class="font-semibold mb-2">Zugehörige Bewerbung</h3>
<div class="text-sm">
<a href="/bewerbungen/{{ application.id }}">Bewerbung #{{ application.id }} öffnen</a>
— vollständige Forensik dort.
</div>
</section>
{% endif %}
{% endblock %}

23
web/templates/logs.html Normal file
View file

@ -0,0 +1,23 @@
{% extends "_layout.html" %}
{% block title %}Logs — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Meine Aktionen (Audit-Log)</h2></div>
<div class="divide-y divide-soft">
{% for e in events %}
<div class="px-4 py-2 mono">
<span class="text-slate-500">{{ e.timestamp }}</span>
<span class="text-slate-400">{{ e.actor }}</span>
<span class="text-slate-700">{{ e.action }}</span>
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
{% if e.ip %}<span class="text-slate-400"> [{{ e.ip }}]</span>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Log-Einträge.</div>
{% endfor %}
</div>
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
Einträge werden nach 14 Tagen automatisch gelöscht.
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,182 @@
{% extends "_layout.html" %}
{% block title %}Wohnungen — lazyflat{% endblock %}
{% block content %}
<!-- Auto-Bewerben + Status-Leiste -->
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
<div class="flex items-center gap-2">
{% if auto_apply_enabled %}
<span class="chip chip-warn">an</span>
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
{% else %}
<span class="chip chip-info">aus</span>
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<!-- der rote Knopf -->
<form method="post" action="/actions/auto-apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match bitte Profil und Filter prüfen.{% endif %}');"
type="submit">
{% if auto_apply_enabled %}AUTO-BEWERBEN: AN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
</button>
</form>
<!-- kill switch -->
<form method="post" action="/actions/kill-switch">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
{% if kill_switch %}Kill-Switch deaktivieren{% else %}Kill-Switch{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
</div>
{% endif %}
<!-- Status zeile -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-3">
<div class="text-xs text-slate-500">alert</div>
<div class="mt-1">
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">apply</div>
<div class="mt-1">
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
{% else %}<span class="chip chip-bad">down</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">submit_forms</div>
<div class="mt-1">
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">Fehler in Serie</div>
<div class="mt-1">
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
{% else %}<span class="chip chip-ok">0</span>{% endif %}
</div>
</div>
</section>
<!-- Filter Panel -->
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
<select class="input" name="wbs_required">
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
</select>
</div>
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
<button class="btn btn-primary" type="submit">Filter speichern</button>
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
</div>
</form>
</details>
<!-- Liste aller Wohnungen -->
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
<span class="text-xs text-slate-500">{{ flats|length }} gesamt</span>
</div>
<div class="divide-y divide-soft">
{% for item in flats %}
{% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
<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.matched %}<span class="chip chip-ok">match</span>{% endif %}
{% if 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">apply fehlgeschlagen</span>
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
</div>
<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 %}
{% if f.sqm_price %} ({{ "%.2f"|format(f.sqm_price) }} €/m²){% endif %}
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
· entdeckt {{ f.discovered_at }}
</div>
{% if item.last and item.last.message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not (item.last and item.last.success == 1) %}
<form method="post" action="/actions/apply">
<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"
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
{% endfor %}
</div>
</section>
{% endblock %}