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:
+166
-20
@@ -3,9 +3,10 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use askama::Template;
|
||||
use axum::extract::State;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::Html;
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
@@ -73,6 +74,15 @@ struct UserCaseView {
|
||||
/// Requires: not currently analyzing, all non-failed recordings
|
||||
/// transcribed, and an LLM is configured.
|
||||
can_analyze: bool,
|
||||
/// True iff the case carries a `.closed` marker. Drives the muted
|
||||
/// styling, the Close/Reopen button swap, and the purge countdown.
|
||||
is_closed: bool,
|
||||
/// Remaining days before auto-purge. `Some(n)` only when the case
|
||||
/// is closed AND `auto_delete_days > 0`; `None` otherwise (so the
|
||||
/// template can render "wird in N Tagen entfernt" vs. plain
|
||||
/// "geschlossen"). Clamped at 0 — cases eligible for purge this
|
||||
/// sweep render as "0" briefly.
|
||||
days_until_purge: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
|
||||
@@ -154,6 +164,30 @@ struct MyCasesTemplate {
|
||||
total: usize,
|
||||
/// True iff the session user is an admin — toggles full case_id display.
|
||||
is_admin: bool,
|
||||
/// Reflects the `?show_closed=1` query param. Controls the toggle
|
||||
/// link state and makes the bulk-purge button + per-case reopen
|
||||
/// buttons visible.
|
||||
show_closed: bool,
|
||||
/// True iff at least one closed case is currently visible. Needed
|
||||
/// to gate the bulk-purge bar so it does not appear on an
|
||||
/// all-open list rendered with `show_closed=1`.
|
||||
any_closed: bool,
|
||||
}
|
||||
|
||||
/// Query params for GET /web/cases and /web/cases/{case_id}.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct CaseListQuery {
|
||||
/// `?show_closed=1` expands the list to include closed cases and
|
||||
/// swaps per-case close buttons with reopen buttons. Any other
|
||||
/// value (missing, "0", empty) keeps the default "open only" view.
|
||||
#[serde(default)]
|
||||
show_closed: Option<String>,
|
||||
}
|
||||
|
||||
impl CaseListQuery {
|
||||
fn include_closed(&self) -> bool {
|
||||
matches!(self.show_closed.as_deref(), Some("1"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -179,6 +213,11 @@ struct CasePageTemplate {
|
||||
analyzing: bool,
|
||||
has_document: bool,
|
||||
is_admin: bool,
|
||||
/// True when the case carries a `.closed` marker. Toggles the
|
||||
/// header action button between close and reopen, and appends
|
||||
/// a "?show_closed=1" to the form action so a freshly reopened
|
||||
/// case still lands on the detail page.
|
||||
is_closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -293,6 +332,7 @@ pub async fn handle_my_cases(
|
||||
State(events_tx): State<EventSender>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(vocab): State<Arc<Gazetteer>>,
|
||||
Query(query): Query<CaseListQuery>,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline
|
||||
@@ -316,14 +356,26 @@ pub async fn handle_my_cases(
|
||||
// reusing the user's retention policy from users.toml. Runs before
|
||||
// the listing scan so any auto-close/auto-purge actions are
|
||||
// reflected in the same response.
|
||||
if let Some(u) = config.users.iter().find(|u| u.slug == user.slug) {
|
||||
crate::retention::sweep_user_retention(&user_root, &user.slug, &u.retention, &events_tx)
|
||||
.await;
|
||||
}
|
||||
let retention = config
|
||||
.users
|
||||
.iter()
|
||||
.find(|u| u.slug == user.slug)
|
||||
.map(|u| u.retention.clone())
|
||||
.unwrap_or_default();
|
||||
crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;
|
||||
|
||||
let show_closed = query.include_closed();
|
||||
let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
||||
let cases = scan_user_cases(&config, &user.slug, busy).await;
|
||||
let cases = scan_user_cases(
|
||||
&config,
|
||||
&user.slug,
|
||||
busy,
|
||||
show_closed,
|
||||
retention.auto_delete_days,
|
||||
)
|
||||
.await;
|
||||
let total = cases.len();
|
||||
let any_closed = cases.iter().any(|c| c.is_closed);
|
||||
let groups = group_by_utc_date(cases);
|
||||
let is_admin = user.is_admin();
|
||||
MyCasesTemplate {
|
||||
@@ -331,6 +383,8 @@ pub async fn handle_my_cases(
|
||||
groups,
|
||||
total,
|
||||
is_admin,
|
||||
show_closed,
|
||||
any_closed,
|
||||
}
|
||||
.render()
|
||||
.map(Html)
|
||||
@@ -343,6 +397,7 @@ pub async fn handle_my_cases(
|
||||
/// shows status (analyzing / placeholder for empty / recordings-summary).
|
||||
/// Action buttons (Analyze/Reanalyze/Reset/Delete) live here, not on the
|
||||
/// recordings sub-page.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn handle_case_page(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
@@ -351,6 +406,7 @@ pub async fn handle_case_page(
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(vocab): State<Arc<Gazetteer>>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
Query(query): Query<CaseListQuery>,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline
|
||||
@@ -364,13 +420,24 @@ pub async fn handle_case_page(
|
||||
)
|
||||
.await;
|
||||
|
||||
let case_dir = locate_case_or_404(
|
||||
let show_closed = query.include_closed();
|
||||
let case_dir = if show_closed {
|
||||
locate_closed_case_or_404(
|
||||
&user_root,
|
||||
&case_id,
|
||||
&user.slug,
|
||||
"case page (show_closed=1)",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
locate_case_or_404(
|
||||
&user_root,
|
||||
&case_id,
|
||||
&user.slug,
|
||||
"case page (possible IDOR probe)",
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
};
|
||||
// Auto-analysis trigger for deep-link navigation: same evaluation as
|
||||
// the list view. Document render below still wins the race if the
|
||||
// worker happens to finish synchronously.
|
||||
@@ -402,6 +469,8 @@ pub async fn handle_case_page(
|
||||
.filter(|s| !s.is_empty());
|
||||
let time_hms_utc = most_recent.map(extract_hhmm_utc).unwrap_or_default();
|
||||
|
||||
let is_closed = crate::paths::is_closed(&case_dir).await;
|
||||
|
||||
CasePageTemplate {
|
||||
case_id: case_id_str,
|
||||
case_id_short,
|
||||
@@ -416,6 +485,7 @@ pub async fn handle_case_page(
|
||||
analyzing: flags.analyzing,
|
||||
has_document: flags.has_document,
|
||||
is_admin,
|
||||
is_closed,
|
||||
}
|
||||
.render()
|
||||
.map(Html)
|
||||
@@ -518,14 +588,47 @@ pub(crate) async fn locate_case_or_404(
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `locate_case_or_404` but accepts closed cases. Only the detail
|
||||
/// view uses this, and only when the user navigated there with
|
||||
/// `?show_closed=1`. A non-existent directory still 404s as expected.
|
||||
pub(crate) async fn locate_closed_case_or_404(
|
||||
user_root: &Path,
|
||||
case_id: &CaseId,
|
||||
user_slug: &str,
|
||||
context: &str,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let p = user_root.join(case_id.to_string());
|
||||
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
|
||||
warn!(
|
||||
slug = %user_slug,
|
||||
case_id = %case_id,
|
||||
context = %context,
|
||||
"case not found (show_closed)",
|
||||
);
|
||||
return Err(AppError::NotFound("Case not found".into()));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Scan all of the given user's cases. Returned vec is sorted by most
|
||||
/// recent recording filename (lexicographic ≈ chronological since
|
||||
/// timestamps are ISO-8601), newest first. Closed cases (with
|
||||
/// `.closed` marker) are excluded.
|
||||
async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<UserCaseView> {
|
||||
/// timestamps are ISO-8601), newest first.
|
||||
///
|
||||
/// `include_closed` flips the default "closed cases are hidden"
|
||||
/// behaviour for the show-closed view. `auto_delete_days` is used to
|
||||
/// compute the per-case `days_until_purge` countdown; pass 0 to
|
||||
/// disable the countdown (maps to `None` on the view).
|
||||
async fn scan_user_cases(
|
||||
config: &Config,
|
||||
slug: &str,
|
||||
worker_busy: bool,
|
||||
include_closed: bool,
|
||||
auto_delete_days: u32,
|
||||
) -> Vec<UserCaseView> {
|
||||
let user_root = config.data_path.join(slug);
|
||||
let llm_configured = config.llm_configured();
|
||||
let mut cases = Vec::new();
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
let mut entries = match tokio::fs::read_dir(&user_root).await {
|
||||
Ok(r) => r,
|
||||
@@ -533,11 +636,18 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let Some((case_id, case_path)) = filter_valid_case_dir(entry).await else {
|
||||
let Some((case_id, case_path)) = filter_valid_case_dir(entry, include_closed).await else {
|
||||
continue;
|
||||
};
|
||||
if let Some(view) =
|
||||
compute_case_view(case_id, &case_path, llm_configured, worker_busy).await
|
||||
if let Some(view) = compute_case_view(
|
||||
case_id,
|
||||
&case_path,
|
||||
llm_configured,
|
||||
worker_busy,
|
||||
auto_delete_days,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
{
|
||||
cases.push(view);
|
||||
}
|
||||
@@ -547,11 +657,14 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
|
||||
cases
|
||||
}
|
||||
|
||||
/// Accept an entry only if it is a non-deleted directory whose name is
|
||||
/// a valid UUID. Returns the (case_id-string, absolute path) tuple so
|
||||
/// the caller does not need to recompute either. Non-UUID names are
|
||||
/// logged at WARN level; other rejections are silent (common case).
|
||||
async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, std::path::PathBuf)> {
|
||||
/// Accept an entry only if it is a directory whose name is a valid UUID.
|
||||
/// Closed cases are rejected unless `include_closed` is set; then the
|
||||
/// caller (the show-closed listing) will downstream-render them with
|
||||
/// muted styling and a badge.
|
||||
async fn filter_valid_case_dir(
|
||||
entry: tokio::fs::DirEntry,
|
||||
include_closed: bool,
|
||||
) -> Option<(String, std::path::PathBuf)> {
|
||||
let case_id = entry.file_name().into_string().ok()?;
|
||||
if uuid::Uuid::parse_str(&case_id).is_err() {
|
||||
warn!(case_id = %case_id, "Skipping non-UUID directory");
|
||||
@@ -561,7 +674,7 @@ async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, st
|
||||
if !case_path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
if crate::paths::is_closed(&case_path).await {
|
||||
if !include_closed && crate::paths::is_closed(&case_path).await {
|
||||
return None;
|
||||
}
|
||||
Some((case_id, case_path))
|
||||
@@ -574,6 +687,8 @@ async fn compute_case_view(
|
||||
case_path: &Path,
|
||||
llm_configured: bool,
|
||||
worker_busy: bool,
|
||||
auto_delete_days: u32,
|
||||
now: OffsetDateTime,
|
||||
) -> Option<UserCaseView> {
|
||||
let recordings = scan_recordings(case_path).await;
|
||||
if recordings.is_empty() {
|
||||
@@ -598,6 +713,8 @@ async fn compute_case_view(
|
||||
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
|
||||
let can_analyze = !analyzing && all_transcribed && llm_configured;
|
||||
|
||||
let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;
|
||||
|
||||
let time_hms_utc = extract_hhmm_utc(&most_recent);
|
||||
let recorded_at_iso = recorded_at_iso_of(&most_recent);
|
||||
Some(UserCaseView {
|
||||
@@ -610,9 +727,38 @@ async fn compute_case_view(
|
||||
analyzing,
|
||||
has_document,
|
||||
can_analyze,
|
||||
is_closed,
|
||||
days_until_purge,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the close marker (if any) and compute the purge countdown.
|
||||
/// Returns `(is_closed, days_until_purge)`. `days_until_purge` is
|
||||
/// `None` whenever the purge sweep is disabled (`auto_delete_days = 0`)
|
||||
/// or the case is still open — the template then suppresses the
|
||||
/// countdown and falls back to the bare "geschlossen" badge.
|
||||
async fn closed_state(
|
||||
case_path: &Path,
|
||||
auto_delete_days: u32,
|
||||
now: OffsetDateTime,
|
||||
) -> (bool, Option<u32>) {
|
||||
let Some(marker) = crate::paths::read_close_marker(case_path).await else {
|
||||
// No marker (open case) OR malformed marker. Malformed should
|
||||
// have been caught by `is_closed` upstream; we treat it as open
|
||||
// here to avoid a bogus countdown on a half-written file.
|
||||
let open_but_marker_present = crate::paths::is_closed(case_path).await;
|
||||
return (open_but_marker_present, None);
|
||||
};
|
||||
if auto_delete_days == 0 {
|
||||
return (true, None);
|
||||
}
|
||||
let age_days = OffsetDateTime::parse(&marker.closed_at, &Rfc3339)
|
||||
.map(|t| (now - t).whole_days().max(0))
|
||||
.unwrap_or(0);
|
||||
let remaining = (auto_delete_days as i64).saturating_sub(age_days).max(0);
|
||||
(true, Some(remaining as u32))
|
||||
}
|
||||
|
||||
/// Extract only the text from a `Ready` state; other states (Empty,
|
||||
/// Error, missing file) collapse to `None`. Used by the recordings
|
||||
/// sub-page, which only needs a plain title string as heading.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user