Refactor case detail and document view
Introduce a `DocumentTemplate` struct for rendering the document view, replacing inline HTML. Introduce a `CaseFlags` struct to encapsulate the logic for determining various UI states of a case (e.g., `has_document`, `analyzing`, `can_close`, `llm_missing`). Update `handle_case_detail` to use `compute_flags` for populating the `CaseDetailTemplate`. Update `scan_user_cases` to compute `analyzing` and `has_document` flags for the `UserCaseView`. Add new template logic in `case_detail.html` to display actions based on `CaseFlags`. Add new template logic in `my_cases.html` to display `analyzing` and `done-doc` labels for cases. Create a new `document.html` template. Add a helper function `any_document_exists` to check for the presence of a document file.
This commit is contained in:
@@ -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!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Dokument v{version}</title></head><body><pre>{escaped}</pre><p><a href=\"/web/cases/{case_id}\">Zurück</a></p></body></html>"
|
||||
)))
|
||||
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`,
|
||||
|
||||
@@ -18,6 +18,8 @@ struct UserCaseView {
|
||||
most_recent: String,
|
||||
oneliner: Option<String>,
|
||||
recordings: Vec<RecordingView>,
|
||||
analyzing: bool,
|
||||
has_document: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -37,6 +39,64 @@ struct CaseDetailTemplate {
|
||||
status: String,
|
||||
oneliner: Option<String>,
|
||||
recordings: Vec<RecordingView>,
|
||||
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<UserCaseView>, 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<UserCaseView>, Ve
|
||||
most_recent,
|
||||
oneliner,
|
||||
recordings,
|
||||
analyzing,
|
||||
has_document,
|
||||
};
|
||||
|
||||
if status == "open" {
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -36,6 +42,18 @@ header form { margin: 0; }
|
||||
{% endmatch %}
|
||||
<div class="meta">{{ case_id }}</div>
|
||||
|
||||
<div class="actions">
|
||||
{% if has_document %}
|
||||
<a class="doc-link" href="/web/cases/{{ case_id }}/document">Ergebnis öffnen</a>
|
||||
{% else if analyzing %}
|
||||
<span class="analyzing">wird analysiert …</span>
|
||||
{% else if can_close %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/close"><button type="submit">Fall abschließen</button></form>
|
||||
{% else if llm_missing %}
|
||||
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Aufnahmen ({{ recordings.len() }})</h2>
|
||||
{% for rec in recordings %}
|
||||
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Dokument v{{ version }}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
.back { color: #4a90e2; text-decoration: none; }
|
||||
pre { white-space: pre-wrap; font-family: serif; font-size: 1.05em; line-height: 1.5; background: #f6f6f6; padding: 1em; border-radius: 4px; }
|
||||
.meta { color: #666; font-family: monospace; font-size: 0.9em; margin-bottom: 1em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases/{{ case_id }}">← Zurück zum Fall</a></div>
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</header>
|
||||
<h1>Dokument v{{ version }}</h1>
|
||||
<div class="meta">{{ case_id }}</div>
|
||||
<pre>{{ content }}</pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,6 +17,9 @@ header form { margin: 0; }
|
||||
section { margin: 1.5em 0; }
|
||||
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
||||
.empty { color: #888; font-style: italic; }
|
||||
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; }
|
||||
.label.analyzing { background: #eee; color: #555; }
|
||||
.label.done-doc { background: #7ed321; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -32,7 +35,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
{% else %}
|
||||
{% for case in open %}
|
||||
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
|
||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)</h2>
|
||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}<span class="label done-doc">ausgewertet</span>{% else if case.analyzing %}<span class="label analyzing">wird analysiert</span>{% endif %}</h2>
|
||||
{% match case.oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
@@ -51,7 +54,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
{% else %}
|
||||
{% for case in done %}
|
||||
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
|
||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)</h2>
|
||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}<span class="label done-doc">ausgewertet</span>{% else if case.analyzing %}<span class="label analyzing">wird analysiert</span>{% endif %}</h2>
|
||||
{% match case.oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
|
||||
Reference in New Issue
Block a user