diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 2e1b96d..49b0ecb 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -153,6 +153,7 @@ struct CaseRecordingsTemplate { oneliner: Option, recordings: Vec, transcribe_busy: bool, + is_admin: bool, } /// Flags derived from filesystem state + config. @@ -349,6 +350,7 @@ pub async fn handle_case_recordings( let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire); let case_id_short = case_id_str.chars().take(8).collect(); + let is_admin = user.is_admin(); CaseRecordingsTemplate { slug: user.slug, @@ -357,6 +359,7 @@ pub async fn handle_case_recordings( oneliner, recordings, transcribe_busy: t_busy, + is_admin, } .render() .map(Html) diff --git a/server/templates/case_page.html b/server/templates/case_page.html index e835ca6..ce3459e 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -8,9 +8,9 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1 header { display: flex; justify-content: space-between; align-items: center; } header form { margin: 0; } .back { color: #4a90e2; text-decoration: none; } -.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; } -.meta { color: #666; font-family: monospace; font-size: 0.9em; } -.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; } +h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } +.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; } +.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; } .status-badge.open { background: #4a90e2; } .status-badge.done { background: #7ed321; } .actions { margin: 1em 0 1.5em; display: flex; align-items: center; gap: 0.4em; } @@ -23,14 +23,17 @@ header form { margin: 0; } .status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; } .recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; } .recordings-link:hover { text-decoration: underline; } -.doc-content { font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; } +.doc-content { position: relative; font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; } .doc-content p { margin: 0.8em 0; } .doc-content p:first-child { margin-top: 0; } .doc-content p:last-child { margin-bottom: 0; } .doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; } -.copy-btn { padding: 0.5em 1em; font-size: 1em; border: 1px solid #4a90e2; background: white; color: #4a90e2; border-radius: 4px; cursor: pointer; font-weight: bold; } -.copy-btn:hover { background: #eaf3fc; } -.toolbar { display: flex; gap: 0.6em; margin: 1em 0; } +.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; } +.copy-btn:hover { color: #4a90e2; opacity: 1; } +.copy-btn .icon-check { display: none; } +.copy-btn.copied { color: #2d8c2d; opacity: 1; } +.copy-btn.copied .icon-copy { display: none; } +.copy-btn.copied .icon-check { display: block; } @@ -38,14 +41,14 @@ header form { margin: 0; }
← Fälle
-

Fall {{ case_id_short }} {% if has_document %}ausgewertet{% else %}offen{% endif %}

+

{% match oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -
Noch kein Oneliner.
+{% when Some with (t) %}{{ t }} +{% when None %}Fall {% endmatch %} -
{{ case_id }}
+{% if has_document %}ausgewertet{% else %}offen{% endif %} +

+{% if is_admin %}
{{ case_id }}
{% endif %}
{% if analyzing %} @@ -63,18 +66,20 @@ header form { margin: 0; } {% match document_html %} {% when Some with (html) %} -
- +
+ +
{{ html|safe }}
-
{{ html|safe }}
diff --git a/server/templates/case_recordings.html b/server/templates/case_recordings.html index b37784b..25de214 100644 --- a/server/templates/case_recordings.html +++ b/server/templates/case_recordings.html @@ -7,9 +7,10 @@ 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; } -.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; } -.meta { color: #666; font-family: monospace; font-size: 0.9em; } +.breadcrumb { display: flex; gap: 0.8em; flex-wrap: wrap; } +.breadcrumb a { color: #4a90e2; text-decoration: none; } +.breadcrumb a:hover { text-decoration: underline; } +.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; } .recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; } .recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; } .recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; } @@ -21,16 +22,19 @@ header form { margin: 0; }
- +

Aufnahmen

-{% match oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -{% endmatch %} -
{{ case_id }}
+{% if is_admin %}
{{ case_id }}
{% endif %}

Aufnahmen ({{ recordings.len() }})

{% for rec in recordings %} diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 8458943..8178335 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -25,6 +25,16 @@ fn make_user(slug: &str) -> User { } } +fn make_admin(slug: &str) -> User { + let mut u = make_user(slug); + u.role = "admin".into(); + u +} + +fn seed_oneliner(case_dir: &Path, text: &str) { + std::fs::write(case_dir.join("oneliner.txt"), text).unwrap(); +} + fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { let api_keys: HashMap = users .iter() @@ -290,6 +300,84 @@ async fn case_recordings_back_link_targets_case_page() { ); } +#[tokio::test] +async fn case_page_uses_oneliner_as_h1_when_present() { + let config = config_with_llm(unique_tmp("cp-h1")); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_oneliner(&case_dir, "Herzkatheter unauffällig"); + std::fs::write(case_dir.join("document.md"), "Inhalt").unwrap(); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let body = body_string(resp).await; + + // H1 wraps the oneliner; the short case_id fallback must be gone. + assert!( + body.contains("

") && body.contains("Herzkatheter unauffällig"), + "h1 should show the oneliner" + ); + // The prominent meta block with the full UUID is admin-only. + assert!( + !body.contains(r#"
"#), + "non-admin must not see the full case_id meta block" + ); +} + +#[tokio::test] +async fn case_page_admin_sees_full_case_id_meta() { + let data_path = unique_tmp("cp-admin"); + let users = vec![make_admin("dr_admin")]; + let config = config_with_llm_users(data_path, "http://unused".into(), users); + let case_id = "11111111-1111-1111-1111-111111111111"; + seed_case(&config.data_path, "dr_admin", case_id); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_admin").await; + + let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let body = body_string(resp).await; + + assert!( + body.contains(case_id), + "admin meta block should expose the full case_id" + ); +} + +#[tokio::test] +async fn case_recordings_breadcrumb_includes_oneliner() { + let config = config_with_llm(unique_tmp("rec-breadcrumb")); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_oneliner(&case_dir, "Kurzer Befund"); + seed_recording(&case_dir, "10-00-00", None); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app + .oneshot(get_page(case_id, &cookie, "/recordings")) + .await + .unwrap(); + let body = body_string(resp).await; + + // Two separate breadcrumb links: back to the case list and back to the case page. + assert!( + body.contains(r#"href="/web/cases""#), + "breadcrumb must link back to the case list" + ); + assert!( + body.contains(&format!(r#"href="/web/cases/{case_id}""#)), + "breadcrumb must link back to the case page" + ); + assert!( + body.contains("Kurzer Befund"), + "breadcrumb must show the oneliner text" + ); +} + #[tokio::test] async fn case_recordings_has_no_action_buttons() { let config = config_with_llm(unique_tmp("rec-ro"));