58b36fbcd9
Introduces a visual indicator for stale analysis on the case page and a new section on the "My Cases" page to highlight cases requiring analysis. This improves user awareness of data currency and pending actions.
259 lines
15 KiB
HTML
259 lines
15 KiB
HTML
{%- import "partials/oneliner.html" as ol -%}
|
|
{%- import "partials/csrf_field.html" as csrf -%}
|
|
<!DOCTYPE html>
|
|
<html lang="de" style="--preview-lines: {{ preview_lines }}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Fälle</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; }
|
|
section { margin: 1.5em 0; }
|
|
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; display: flex; justify-content: space-between; align-items: baseline; gap: 1em; }
|
|
section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
|
.empty { color: #888; font-style: italic; }
|
|
.pending { color: #888; font-style: italic; }
|
|
|
|
.case-list { list-style: none; padding: 0; margin: 0; }
|
|
.case-row { display: grid; grid-template-columns: 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
|
|
.case-list.has-bulk .case-row { grid-template-columns: auto 1fr auto; }
|
|
html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr auto; }
|
|
.case-row.done { border-left: 4px solid #7ed321; }
|
|
.case-row.open { border-left: 4px solid #4a90e2; }
|
|
.case-row .check { align-self: start; padding-top: 0.25em; }
|
|
.case-row .body { min-width: 0; }
|
|
.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; }
|
|
.case-row .body small { color: #666; font-family: monospace; font-size: 0.85em; }
|
|
.case-row .oneliner { color: #333; margin: 0.2em 0 0; }
|
|
.case-row .line1 { font-size: 1em; margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.25em 0.6em; }
|
|
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
|
|
.case-row .case-link { color: inherit; text-decoration: none; min-width: 0; }
|
|
.case-row .case-link:hover { text-decoration: underline; }
|
|
.case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; }
|
|
.case-row .recordings-link:hover { text-decoration: underline; }
|
|
.case-row .manual-marker { font-size: 0.9em; opacity: 0.55; margin-left: 0.15em; cursor: help; }
|
|
.case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; }
|
|
.case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; }
|
|
.case-row .analysis.open .arrow { transform: rotate(90deg); }
|
|
.case-row .analysis-content { color: #555; font-size: 0.9em; min-width: 0; flex: 1 1 auto; display: -webkit-box; -webkit-line-clamp: var(--preview-lines); line-clamp: var(--preview-lines); -webkit-box-orient: vertical; overflow: hidden; }
|
|
.case-row .analysis-content > :first-child { margin-top: 0; }
|
|
.case-row .analysis-content > :last-child { margin-bottom: 0; }
|
|
.case-row .analysis-content p { display: inline; margin: 0; }
|
|
.case-row .analysis-content mark { background: #fff3a0; padding: 0 0.15em; border-radius: 2px; }
|
|
.case-row .analysis.open .analysis-content { display: block; overflow: visible; }
|
|
.case-row .analysis.open .analysis-content p { display: block; margin: 0 0 0.5em; }
|
|
.case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; }
|
|
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; margin-left: 0.7em; }
|
|
.status-badge.open { background: #4a90e2; }
|
|
.status-badge.done { background: #7ed321; }
|
|
.status-badge.fehler { background: #e24a4a; }
|
|
.status-badge.required { background: #f5a623; }
|
|
|
|
.required-bar { margin: 1em 0; padding: 0.8em 1em; background: #fff7e6; border: 1px solid #f5a623; border-radius: 4px; display: flex; gap: 0.6em; align-items: center; }
|
|
.required-bar button { font-size: 0.9em; padding: 0.5em 1em; }
|
|
.required-bar .count { font-weight: bold; color: #8a5a00; }
|
|
.case-row .actions { display: flex; gap: 0.4em; align-items: center; }
|
|
.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 .case-link { 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; }
|
|
.label.done-doc { background: #7ed321; color: white; }
|
|
|
|
.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.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; }
|
|
html.admin-view-off .admin-only { display: none !important; }
|
|
</style>
|
|
<script>
|
|
try {
|
|
if (localStorage.getItem('admin-view') === 'off') {
|
|
document.documentElement.classList.add('admin-view-off');
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>{{ display_name }}</h1>
|
|
<div class="header-right">
|
|
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
|
<form method="post" action="/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
|
|
</div>
|
|
</header>
|
|
|
|
{% if show_closed %}
|
|
<a class="closed-toggle" href="/cases">← Nur offene Fälle anzeigen</a>
|
|
{% else %}
|
|
<a class="closed-toggle" href="/cases?show_closed=1">Geschlossene Fälle einblenden</a>
|
|
{% endif %}
|
|
|
|
{% if total == 0 %}
|
|
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
|
|
{% else %}
|
|
{% if required_count > 0 %}
|
|
<form class="required-bar" method="post" action="/cases/analyze-required">
|
|
{% call csrf::field(csrf_token) %}
|
|
<span class="count">{{ required_count }} Fälle erfordern Analyse</span>
|
|
<button type="submit">Aktualisieren</button>
|
|
</form>
|
|
{% endif %}
|
|
<form method="post" action="/cases/bulk">
|
|
{% call csrf::field(csrf_token) %}
|
|
<input type="hidden" name="return_to" value="/cases{% if show_closed %}?show_closed=1{% endif %}">
|
|
|
|
{% for g in groups %}
|
|
<section>
|
|
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{{ g.open_count }}/{{ g.total_count }} Fälle</span></h2>
|
|
<ul class="case-list{% if is_admin %} has-bulk{% endif %}">
|
|
{% for case in g.cases %}
|
|
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}">
|
|
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
|
|
<div class="body">
|
|
<div class="line1">
|
|
<a class="case-link" href="/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else 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.analysis_required && !case.analyzing && !case.is_closed %} <span class="status-badge required">Analyse erforderlich</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 %}</a>
|
|
<a class="recordings-link" href="/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
|
|
</div>
|
|
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow">▸</span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
|
|
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
|
</div>
|
|
<div class="actions">
|
|
{% if is_admin %}<button type="submit" formaction="/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
|
|
<button type="submit" formaction="/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
|
|
{% if case.is_closed %}
|
|
<button type="submit" formaction="/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="/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 %}
|
|
</ul>
|
|
</section>
|
|
{% endfor %}
|
|
{% if is_admin %}<div class="bulk-bar admin-only">
|
|
<span>Auswahl:</span>
|
|
<button type="button" onclick="const b=document.querySelectorAll('input[name=case_id]');const all=Array.from(b).every(c=>c.checked);b.forEach(c=>c.checked=!all);this.textContent=all?'Alles auswählen':'Keine auswählen'">Alles auswählen</button>
|
|
<button type="submit" name="action" value="analyze">Analysieren</button>
|
|
<button type="submit" name="action" value="reset">Reset</button>
|
|
<button type="submit" name="action" value="close">Schließen</button>
|
|
</div>{% endif %}
|
|
</form>
|
|
{% if is_admin && show_closed && any_closed %}
|
|
<form class="bulk-bar purge admin-only" method="post" action="/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
|
|
{% call csrf::field(csrf_token) %}
|
|
<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" %}
|
|
</script>
|
|
<script>
|
|
// Render all UTC timestamps in the browser's local timezone, and relabel
|
|
// date-group headings to Heute/Gestern/DD.MM.YYYY.
|
|
(() => {
|
|
document.querySelectorAll('time[datetime]').forEach((el) => {
|
|
const d = new Date(el.dateTime);
|
|
if (!isNaN(d.getTime())) el.textContent = TimeFmt.timeLabel(d);
|
|
});
|
|
document.querySelectorAll('h2.date-group').forEach((h) => {
|
|
const iso = h.dataset.iso;
|
|
if (!iso) return;
|
|
const labelEl = h.querySelector('.date-label');
|
|
if (labelEl) labelEl.textContent = TimeFmt.dateLabelFromISO(iso);
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const cb = document.getElementById('admin-view-toggle');
|
|
if (!cb) return;
|
|
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
|
cb.addEventListener('change', () => {
|
|
const on = cb.checked;
|
|
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
|
document.documentElement.classList.toggle('admin-view-off', !on);
|
|
});
|
|
})();
|
|
|
|
// Preserve scroll position across POST-redirects (close, reopen,
|
|
// bulk-*). Browsers keep scroll on location.reload() but NOT on a
|
|
// redirect-driven navigation, even when the target URL matches the
|
|
// referer. Without this, clicking close on row 30 of a long list
|
|
// dumps the user back at scrollY=0 with the action-row off-screen.
|
|
//
|
|
// Mechanism: capture scrollY in sessionStorage before every form
|
|
// submit on this page, restore it on the very next load, then delete.
|
|
// Using the classic two-arg scrollTo(0, y) form — the options-object
|
|
// variant is silently ignored in some older browsers.
|
|
(() => {
|
|
const STASH = 'doctate:scrollY';
|
|
try {
|
|
document.querySelectorAll('form').forEach((f) => {
|
|
f.addEventListener('submit', () => {
|
|
sessionStorage.setItem(STASH, String(window.scrollY));
|
|
});
|
|
});
|
|
const stashed = sessionStorage.getItem(STASH);
|
|
if (stashed !== null) {
|
|
sessionStorage.removeItem(STASH);
|
|
const y = parseInt(stashed, 10);
|
|
if (!isNaN(y)) {
|
|
window.scrollTo(0, y);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// sessionStorage may be blocked (strict privacy mode) — silent fail,
|
|
// scroll returns to 0 like it did before this feature.
|
|
}
|
|
})();
|
|
|
|
// Live-update bus: any case event for this user triggers a debounced
|
|
// page reload. Debounce collapses event bursts (e.g. bulk-delete) into
|
|
// one reload. EventSource auto-reconnects on network drops.
|
|
(() => {
|
|
let timer = null;
|
|
const scheduleReload = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => location.reload(), 300);
|
|
};
|
|
const es = new EventSource('/events');
|
|
// Release the socket-pool slot on navigation. Without this, Chrome
|
|
// keeps the SSE connection "draining" after unload; six rapid nav
|
|
// cycles exhaust the 6-per-origin pool and stall further requests.
|
|
window.addEventListener('pagehide', () => es.close());
|
|
es.addEventListener('case', () => scheduleReload());
|
|
})();
|
|
|
|
// Expandable analysis: click anywhere on .analysis toggles the `open`
|
|
// class. Links/buttons inside the analysis are preserved — a click on
|
|
// a <mark>-wrapped term shouldn't accidentally expand (no such case
|
|
// today, but the guard is free). Delegation keeps one listener across
|
|
// all case rows, no per-row wiring.
|
|
document.addEventListener('click', (e) => {
|
|
const exp = e.target.closest('[data-expand]');
|
|
if (!exp) return;
|
|
if (e.target.closest('a, button, input')) return;
|
|
exp.classList.toggle('open');
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|