feat: show-closed toggle, closed badge, reopen button, bulk purge

GET /web/cases now honours ?show_closed=1: the toggle link flips the
listing between open-only (default) and open+closed (muted styling,
grey "geschlossen" badge, countdown "wird in N Tagen entfernt" when
auto_delete_days > 0). Each closed row shows a Lucide rotate-ccw
reopen button in the same slot where the close button sits on open
cases; selection checkboxes are suppressed so bulk-close cannot touch
closed cases.

GET /web/cases/:id honours ?show_closed=1 too: without the query a
closed case still 404s (keeps the default listing clean), with it the
detail page renders the reopen form in the header. The case_page
template swaps the icon based on is_closed.

The purge-closed bulk button from the earlier commit finally gets a
UI: it appears below the listing when show_closed=1 is active AND at
least one closed case is visible, with a JS confirm() + the
server-side confirm=yes guard. Two integration tests (listing +
detail) cover the happy path.
This commit is contained in:
2026-04-21 10:58:00 +02:00
parent f358da215d
commit 23e828df45
4 changed files with 356 additions and 33 deletions
+32
View File
@@ -261,6 +261,37 @@
>{% else %}<span class="status-badge open">offen</span>{% endif
%}
</span>
{% if is_closed %}
<form
class="delete-form"
method="post"
action="/web/cases/{{ case_id }}/reopen"
>
<button
type="submit"
class="delete-btn"
title="Fall wiedereröffnen"
aria-label="Fall wiedereröffnen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
</svg>
</button>
</form>
{% else %}
<form
class="delete-form"
method="post"
@@ -293,6 +324,7 @@
</svg>
</button>
</form>
{% endif %}
</h1>
{% match recorded_at_iso %} {% when Some with (iso) %}
<p class="case-time">
+34 -8
View File
@@ -36,6 +36,14 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
.case-row .actions .delete-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
.case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
.case-row .actions .reopen-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
.case-row .actions .reopen-btn:hover { color: #4a90e2; background: #eef4fb; }
.case-row.closed { opacity: 0.65; background: #fafafa; }
.case-row.closed .body a { color: #777; }
.case-row .closed-badge { display: inline-block; margin-left: 0.5em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; background: #ddd; color: #444; }
.closed-toggle { display: block; margin: 0.5em 0 1em; font-size: 0.9em; color: #4a90e2; text-decoration: none; }
.closed-toggle:hover { text-decoration: underline; }
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; }
.label.analyzing { background: #eee; color: #555; }
@@ -43,8 +51,9 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; }
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
.bulk-bar .undo { margin-left: auto; }
.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; }
.bulk-bar.purge { background: #fff0f0; border: 1px solid #e24a4a; }
.bulk-bar.purge button { background: #e24a4a; color: white; border: none; }
.bulk-bar.purge button:hover { background: #c43030; }
.header-right { display: flex; align-items: center; gap: 0.8em; }
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
@@ -67,8 +76,14 @@ try {
</div>
</header>
{% if show_closed %}
<a class="closed-toggle" href="/web/cases">← Nur offene Fälle anzeigen</a>
{% else %}
<a class="closed-toggle" href="/web/cases?show_closed=1">Geschlossene Fälle einblenden</a>
{% endif %}
{% if total == 0 %}
<section><p class="empty">Keine Fälle.</p></section>
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
{% for g in groups %}
@@ -76,19 +91,23 @@ try {
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{% if g.cases.len() == 1 %}1 Fall{% else %}{{ g.cases.len() }} Fälle{% endif %}</span></h2>
<ul class="case-list">
{% for case in g.cases %}
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}">
<div class="check">{% if !case.is_closed %}<input type="checkbox" name="case_id" value="{{ case.case_id }}">{% endif %}</div>
<div class="body">
<a href="/web/cases/{{ case.case_id }}">
<a href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}">
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %}<span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</div>
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
</a>
</div>
<div class="actions">
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
{% if !case.is_closed && is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
{% if case.is_closed %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
{% else %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
{% endif %}
</div>
</li>
{% endfor %}
@@ -103,6 +122,13 @@ try {
<button type="submit" name="action" value="close">Schließen</button>
</div>
</form>
{% if show_closed && any_closed %}
<form class="bulk-bar purge" method="post" action="/web/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
<input type="hidden" name="confirm" value="yes">
<span>Geschlossene Fälle:</span>
<button type="submit">Alle geschlossenen unwiderruflich löschen</button>
</form>
{% endif %}
{% endif %}
<script>
{% include "partials/time_format.js" %}