diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs
index 253d05c..42d0558 100644
--- a/server/src/routes/case_actions.rs
+++ b/server/src/routes/case_actions.rs
@@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
+use askama::Template;
use axum::extract::{Path as AxumPath, State};
use axum::response::{Html, Redirect};
use time::format_description::well_known::Rfc3339;
@@ -15,6 +16,14 @@ use crate::config::Config;
use crate::error::AppError;
use crate::routes::user_web::locate_case;
+#[derive(Template)]
+#[template(path = "document.html")]
+struct DocumentTemplate {
+ case_id: String,
+ version: u32,
+ content: String,
+}
+
/// POST /web/cases/{case_id}/close
///
/// Persist an `analysis_input_v1.json` for the case and enqueue an analyze
@@ -152,14 +161,14 @@ pub async fn handle_document_view(
.await
.ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?;
- // Minimal inline HTML for step 4; replaced with a proper template in step 7.
- let escaped = content
- .replace('&', "&")
- .replace('<', "<")
- .replace('>', ">");
- Ok(Html(format!(
- "
Dokument v{version}{escaped}Zurück
"
- )))
+ DocumentTemplate {
+ case_id,
+ version,
+ content,
+ }
+ .render()
+ .map(Html)
+ .map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`,
diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs
index 8d17033..4da20b8 100644
--- a/server/src/routes/user_web.rs
+++ b/server/src/routes/user_web.rs
@@ -18,6 +18,8 @@ struct UserCaseView {
most_recent: String,
oneliner: Option,
recordings: Vec,
+ analyzing: bool,
+ has_document: bool,
}
#[derive(Template)]
@@ -37,6 +39,64 @@ struct CaseDetailTemplate {
status: String,
oneliner: Option,
recordings: Vec,
+ can_close: bool,
+ llm_missing: bool,
+ analyzing: bool,
+ has_document: bool,
+}
+
+/// Four mutually exclusive flags derived from filesystem state + config.
+/// Priority order (for UI rendering): `has_document`, then `analyzing`,
+/// then `can_close`, then `llm_missing`. If none is true, the case is
+/// still receiving recordings or has zero transcripts; no action surfaced.
+struct CaseFlags {
+ has_document: bool,
+ analyzing: bool,
+ can_close: bool,
+ llm_missing: bool,
+}
+
+async fn compute_flags(
+ case_dir: &Path,
+ status: &str,
+ recordings: &[RecordingView],
+ llm_configured: bool,
+) -> CaseFlags {
+ let has_document = any_document_exists(case_dir).await;
+ let analyzing = !has_document
+ && tokio::fs::try_exists(case_dir.join("analysis_input_v1.json"))
+ .await
+ .unwrap_or(false);
+
+ let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
+ let all_transcribed =
+ !non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
+
+ let transcribed_stage = status == "open" && all_transcribed && !has_document && !analyzing;
+
+ CaseFlags {
+ has_document,
+ analyzing,
+ can_close: transcribed_stage && llm_configured,
+ llm_missing: transcribed_stage && !llm_configured,
+ }
+}
+
+/// True iff the case directory contains at least one `document_v{N}.md`.
+/// We don't care about N here — any version means "Ausgewertet" for the UI.
+async fn any_document_exists(case_dir: &Path) -> bool {
+ let mut entries = match tokio::fs::read_dir(case_dir).await {
+ Ok(r) => r,
+ Err(_) => return false,
+ };
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let name = entry.file_name();
+ let Some(name_str) = name.to_str() else { continue };
+ if name_str.starts_with("document_v") && name_str.ends_with(".md") {
+ return true;
+ }
+ }
+ false
}
pub async fn handle_my_cases(
@@ -80,6 +140,7 @@ pub async fn handle_case_detail(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
+ let flags = compute_flags(&case_dir, &status, &recordings, config.llm_configured()).await;
let case_id_short = case_id.chars().take(8).collect();
CaseDetailTemplate {
@@ -89,6 +150,10 @@ pub async fn handle_case_detail(
status,
oneliner,
recordings,
+ can_close: flags.can_close,
+ llm_missing: flags.llm_missing,
+ analyzing: flags.analyzing,
+ has_document: flags.has_document,
}
.render()
.map(Html)
@@ -139,17 +204,26 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec, Ve
continue;
}
+ let case_path = case_entry.path();
let most_recent = recordings
.last()
.map(|r| r.filename.clone())
.unwrap_or_default();
let case_id_short = case_id.chars().take(8).collect();
- let oneliner = tokio::fs::read_to_string(case_entry.path().join("oneliner.txt"))
+ let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
+ // Only the two flags the list UI needs are computed here —
+ // `can_close`/`llm_missing` live on the detail page.
+ let has_document = any_document_exists(&case_path).await;
+ let analyzing = !has_document
+ && tokio::fs::try_exists(case_path.join("analysis_input_v1.json"))
+ .await
+ .unwrap_or(false);
+
let view = UserCaseView {
case_id,
case_id_short,
@@ -157,6 +231,8 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec, Ve
most_recent,
oneliner,
recordings,
+ analyzing,
+ has_document,
};
if status == "open" {
diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html
index 8ca2618..89c9293 100644
--- a/server/templates/case_detail.html
+++ b/server/templates/case_detail.html
@@ -20,6 +20,12 @@ header form { margin: 0; }
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; }
.status-badge.open { background: #4a90e2; }
.status-badge.done { background: #7ed321; }
+.actions { margin: 1em 0 1.5em; }
+.actions form { margin: 0; display: inline; }
+.actions button { font-size: 1em; padding: 0.5em 1em; }
+.actions a.doc-link { display: inline-block; padding: 0.5em 1em; background: #7ed321; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; }
+.actions .analyzing { color: #888; font-style: italic; }
+.actions .llm-missing { color: #a66; font-style: italic; }
@@ -36,6 +42,18 @@ header form { margin: 0; }
{% endmatch %}
{{ case_id }}
+
+{% if has_document %}
+
Ergebnis öffnen
+{% else if analyzing %}
+
wird analysiert …
+{% else if can_close %}
+
+{% else if llm_missing %}
+
LLM-Analyse nicht konfiguriert
+{% endif %}
+
+
Aufnahmen ({{ recordings.len() }})
{% for rec in recordings %}