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
+119
View File
@@ -732,6 +732,125 @@ async fn reopen_removes_marker_and_restores_case() {
assert_eq!(detail.status(), StatusCode::OK);
}
#[tokio::test]
async fn show_closed_query_includes_closed_cases() {
let config = config_with_llm(unique_tmp("show-closed"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("a closed one"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
// Close first.
let _ = app
.clone()
.oneshot(close_request(case_id, &cookie))
.await
.unwrap();
// Default listing must NOT show it.
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/web/cases")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
!body.contains(case_id),
"default /web/cases must hide closed cases"
);
// With show_closed=1 it must appear, with the closed badge.
let resp = app
.oneshot(
Request::builder()
.uri("/web/cases?show_closed=1")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains(case_id),
"show_closed=1 must list closed cases"
);
assert!(
body.contains("geschlossen"),
"closed badge must render in show_closed mode"
);
assert!(
body.contains("reopen"),
"reopen button must be rendered for closed cases"
);
}
#[tokio::test]
async fn closed_case_detail_with_show_closed_returns_200() {
let config = config_with_llm(unique_tmp("show-detail"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("x"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let _ = app
.clone()
.oneshot(close_request(case_id, &cookie))
.await
.unwrap();
// Without query → 404 (covered by closed_case_returns_404_on_detail).
// With ?show_closed=1 → 200 with the reopen form.
let resp = app
.oneshot(
Request::builder()
.uri(format!("/web/cases/{case_id}?show_closed=1"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains(&format!("/web/cases/{case_id}/reopen")),
"detail page of a closed case must offer a reopen form"
);
assert!(
!body.contains(&format!("/web/cases/{case_id}/close\"")),
"detail page of a closed case must NOT offer a close form"
);
}
#[tokio::test]
async fn reopen_is_noop_on_open_case() {
let config = config_with_llm(unique_tmp("reopen-2"), "http://unused".into());