feat(studio): Einstellungen-Reiter je Fall, KI-Weg wählbar und sichtbar

Das Studio konnte Fälle bisher nur anlegen, nicht bearbeiten. Es gab kein
Einstellungs-Panel, alle Felder waren nach dem Anlegen eingefroren. Wer etwas
ändern wollte, musste zurück in die klassische Ansicht. Für die geplante
Ablösung der klassischen Ansicht ist das der erste Blocker.

Neuer Reiter Einstellungen in der Fallnavigation, unter Export. Er führt alle
Felder des klassischen Bearbeiten-Modals: Titel, Beschreibung, die drei
Quellenschalter, Sichtbarkeit, KI-Verarbeitung, Aktualisierung mit Intervall
und Startzeit, Aufbewahrung und die drei E-Mail-Benachrichtigungen.

Gespeichert wird nur, was sich geändert hat. Zwei Gründe. Der Server
aktualisiert feldweise, unverändert zurückgereichte Werte würden trotzdem
geschrieben. Und eine unbeabsichtigte Änderung am Intervall kann einen
laufenden Zeitplan verstellen.

Die Wahl des KI-Wegs, also Anthropic gegen die EU-Fassung über Bedrock
Frankfurt, fehlte im Studio vollständig. Sie steht jetzt im Reiter und im
Anlege-Dialog, wortgleich zur klassischen Ansicht. Beim Umstellen erscheint
ein Hinweis, dass bereits erzeugte Inhalte auf dem bisherigen Weg entstanden
sind und die Änderung ab dem nächsten Lauf greift. Läuft gerade ein Schritt,
sagt der Reiter das ebenfalls.

Dazu die Kennzeichen EU und Anthropic in Fallliste und Kopfzeile. Sie
erscheinen nur, wenn der Fall ausdrücklich auf einen Weg gestellt ist. Ohne
Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.

Zwei Feinheiten. Das Intervall wird in der größten glatt aufgehenden Einheit
gezeigt, 1440 Minuten also als ein Tag. Und der X-Schalter bleibt bedienbar,
wenn der Fall X bereits nutzt, sonst ließe er sich nach dem Wegfall des
letzten Zugangs nicht mehr abschalten.

Prüfungen ohne Browser und ohne Netzzugriff. 57 Fälle für den Reiter, dazu 29
für die Struktur beider Oberflächen, die prüfen, dass jedes onclick auf eine
vorhandene Methode zeigt, dass keine ID doppelt vergeben ist und dass jedes
Design-Token ohne Fallback auch definiert ist.

Cache-Buster auf 20260802b.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-08-02 18:30:27 +00:00
Ursprung c09c2a49b9
Commit 4ba66cb510
7 geänderte Dateien mit 985 neuen und 9 gelöschten Zeilen

Datei anzeigen

@@ -260,6 +260,7 @@ const Studio = {
</label>` : ''}
<button class="case-open" onclick="${sel ? `Studio.toggleCaseSel(${i.id}, !Studio.caseSel.has(${i.id}))` : `Studio.selectIncident(${i.id})`}" title="${UI.escape(i.title)}">
<span class="case-title">${UI.escape(i.title)}</span>
${Studio._aiTag(i, 'case-ai-tag')}
<span class="case-count">${i.article_count}</span>
</button>
${sel ? '' : `<button class="case-menu-btn" onclick="Studio.caseMenu(event, ${i.id})" title="Mehr" aria-label="Mehr">
@@ -691,6 +692,10 @@ const Studio = {
badge.textContent = research ? 'Analyse' : 'Live';
badge.className = 'studio-type-badge' + (research ? ' type-research' : '');
badge.style.display = '';
// KI-Weg nur zeigen, wenn der Fall ausdruecklich darauf steht. Ohne
// Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.
const aiBadge = document.getElementById('studio-ai-badge');
if (aiBadge) aiBadge.innerHTML = this._aiTag(inc, 'incident-ai-badge');
document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild';
document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen';
},
@@ -1333,6 +1338,233 @@ const Studio = {
},
// === Studio-Tabs in der Mitte ===
// === Einstellungen des Falls =========================================
// Der Reiter ersetzt das Bearbeiten-Modal der klassischen Ansicht. Er
// schickt beim Speichern nur, was sich wirklich geaendert hat. Zwei Gruende.
// Der Server aktualisiert feldweise, alles Mitgeschickte wuerde sonst
// ueberschrieben, auch wenn es nur unveraendert zurueckgereicht wird. Und
// eine unbeabsichtigte Aenderung am Intervall kann einen laufenden Zeitplan
// verstellen.
_setEl(id) { return document.getElementById(id); },
/** Liest das Formular. Der Rueckgabewert ist mit dem Schnappschuss vergleichbar. */
_settingsWerte() {
const g = (id) => this._setEl(id);
const einheit = parseInt(g('set-refresh-unit').value) || 1;
const zahl = parseInt(g('set-refresh-value').value) || 15;
const auto = g('set-refresh-mode').value === 'auto';
const werte = {
title: g('set-title').value.trim(),
description: g('set-description').value.trim(),
international_sources: g('set-international').checked,
include_telegram: g('set-telegram').checked,
include_x: g('set-x').checked,
visibility: g('set-visibility').checked ? 'public' : 'private',
ai_backend: g('set-ai-backend').value || '',
refresh_mode: g('set-refresh-mode').value,
refresh_interval: Math.max(10, Math.min(10080, zahl * einheit)),
retention_days: Math.max(0, Math.min(999, parseInt(g('set-retention').value) || 0)),
};
// Die Startzeit gehoert nur zur automatischen Aktualisierung. Bei
// manueller Aktualisierung bleibt der gespeicherte Wert unangetastet,
// ausgewertet wird er dort ohnehin nicht.
if (auto) werte.refresh_start_time = g('set-refresh-starttime').value || '07:00';
return werte;
},
_settingsAbo() {
return {
notify_email_summary: this._setEl('set-notify-summary').checked,
notify_email_new_articles: this._setEl('set-notify-new-articles').checked,
notify_email_status_change: this._setEl('set-notify-status-change').checked,
};
},
/** Fuellt das Formular aus dem offenen Fall. Dient auch als Verwerfen. */
async loadSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const g = (id) => this._setEl(id);
g('set-title').value = inc.title || '';
g('set-description').value = inc.description || '';
g('set-international').checked = !!inc.international_sources;
g('set-telegram').checked = !!inc.include_telegram;
g('set-x').checked = !!inc.include_x;
g('set-visibility').checked = (inc.visibility || 'public') === 'public';
g('set-ai-backend').value = inc.ai_backend || '';
g('set-refresh-mode').value = inc.refresh_mode === 'auto' ? 'auto' : 'manual';
g('set-refresh-starttime').value = inc.refresh_start_time || '07:00';
g('set-retention').value = (inc.retention_days === null || inc.retention_days === undefined)
? 0 : inc.retention_days;
// Intervall in die groesste Einheit zeigen, die glatt aufgeht. 1440
// Minuten liest sich als 1 Tag besser denn als 1440 Minuten.
const iv = parseInt(inc.refresh_interval) || 15;
let einheit = 1;
for (const u of [10080, 1440, 60]) { if (iv % u === 0) { einheit = u; break; } }
g('set-refresh-unit').value = String(einheit);
g('set-refresh-value').value = String(Math.round(iv / einheit));
this.settingsRefreshToggle();
this.settingsIntervalMin();
this.settingsVisibilityHint();
this._settingsRunningNote();
// Der Schnappschuss ist die Vergleichsgrundlage fuers Speichern. Er
// wird vor dem Abo-Nachladen gesetzt, damit ein langsamer Abruf das
// Formular nicht in einem halben Zustand zuruecklaesst.
this._settingsAlt = this._settingsWerte();
this.settingsAiChanged();
this._settingsState('');
try {
const abo = await API.getSubscription(inc.id);
g('set-notify-summary').checked = !!(abo && abo.notify_email_summary);
g('set-notify-new-articles').checked = !!(abo && abo.notify_email_new_articles);
g('set-notify-status-change').checked = !!(abo && abo.notify_email_status_change);
} catch (e) {
// Ohne Abo-Stand bleiben die Haken aus. Das ist der Serverzustand
// fuer einen Fall ohne Abo.
g('set-notify-summary').checked = false;
g('set-notify-new-articles').checked = false;
g('set-notify-status-change').checked = false;
}
this._settingsAboAlt = this._settingsAbo();
// Bewusst abgewartet. Erst danach ist das Formular vollstaendig,
// vorher stuende der X-Schalter noch auf dem Ausgangswert.
await this._settingsXHint();
},
settingsRefreshToggle() {
const auto = this._setEl('set-refresh-mode').value === 'auto';
this._setEl('set-interval-field').classList.toggle('visible', auto);
this._setEl('set-starttime-field').classList.toggle('visible', auto);
},
settingsIntervalMin() {
const einheit = parseInt(this._setEl('set-refresh-unit').value);
const feld = this._setEl('set-refresh-value');
const min = einheit === 1 ? 10 : 1;
feld.min = min;
if (parseInt(feld.value) < min) feld.value = min;
},
settingsVisibilityHint() {
const oeffentlich = this._setEl('set-visibility').checked;
this._setEl('set-visibility-text').textContent = oeffentlich
? 'Öffentlich, für alle Nutzer sichtbar'
: 'Privat, nur für dich sichtbar';
},
/** Warnt, sobald der KI-Weg vom gespeicherten Stand abweicht. */
settingsAiChanged() {
const w = this._setEl('set-ai-warn');
if (!w) return;
const jetzt = this._setEl('set-ai-backend').value || '';
const alt = (this._settingsAlt && this._settingsAlt.ai_backend) || '';
w.hidden = jetzt === alt;
},
_settingsRunningNote() {
const n = this._setEl('set-running-note');
if (n) n.hidden = !this._runningStage;
},
_settingsState(text, art) {
const s = this._setEl('set-state');
if (!s) return;
s.textContent = text || '';
s.className = 'settings-state' + (art ? ' ' + art : '');
},
/** X laesst sich nur einschalten, wenn ein Zugang hinterlegt ist. */
async _settingsXHint() {
const cb = this._setEl('set-x');
const hint = this._setEl('set-x-hint');
if (!cb) return;
let aktiv = 0;
try {
const konten = await API.listXAccounts();
aktiv = (konten || []).filter(a => a.active).length;
} catch (e) {
aktiv = 0; // im Zweifel sperren
}
// Nutzt der Fall X bereits, bleibt der Schalter bedienbar. Sonst koennte
// man ihn nach dem Wegfall des letzten Zugangs nicht mehr abschalten.
cb.disabled = aktiv === 0 && !cb.checked;
if (hint) hint.hidden = aktiv !== 0;
},
async saveSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const knopf = this._setEl('set-save');
const jetzt = this._settingsWerte();
if (!jetzt.title) {
UI.showToast('Ohne Titel geht es nicht', 'error');
this._setEl('set-title').focus();
return;
}
const alt = this._settingsAlt || {};
const patch = {};
Object.keys(jetzt).forEach(k => {
if (jetzt[k] !== alt[k]) patch[k] = jetzt[k];
});
const abo = this._settingsAbo();
const aboAlt = this._settingsAboAlt || {};
const aboGeaendert = Object.keys(abo).some(k => abo[k] !== aboAlt[k]);
if (!Object.keys(patch).length && !aboGeaendert) {
this._settingsState('Nichts geändert');
return;
}
knopf.disabled = true;
this._settingsState('Wird gespeichert …');
try {
if (Object.keys(patch).length) {
const neu = await API.updateIncident(inc.id, patch);
// Der Server antwortet mit der angereicherten Lage. Sie ist die
// verlaessliche Quelle, das Formular koennte abweichen.
this.incident = (neu && neu.id) ? neu : Object.assign({}, inc, patch);
const i = this.incidents.findIndex(x => x.id === inc.id);
if (i >= 0) this.incidents[i] = Object.assign({}, this.incidents[i], this.incident);
}
if (aboGeaendert) await API.updateSubscription(inc.id, abo);
this._settingsAlt = this._settingsWerte();
this._settingsAboAlt = abo;
this.settingsAiChanged();
this.renderHeader(this.incident);
this.renderCases();
this._settingsState('Gespeichert', 'ok');
UI.showToast('Einstellungen gespeichert', 'success');
} catch (e) {
this._settingsState('Nicht gespeichert', 'fehler');
UI.showToast((e && e.message) || 'Einstellungen konnten nicht gespeichert werden', 'error');
} finally {
knopf.disabled = false;
}
},
/** Kennzeichen des KI-Wegs. Leer, solange die Org-Vorgabe gilt. */
_aiTag(inc, klasse) {
const b = inc && inc.ai_backend;
if (b === 'bedrock') {
return `<span class="${klasse} ai-eu" title="KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche über den europäischen Suchindex staan">EU</span>`;
}
if (b === 'cli') {
return `<span class="${klasse} ai-anthropic" title="KI-Verarbeitung über Anthropic mit eingebauter Websuche (heutiger Weg)">Anthropic</span>`;
}
return '';
},
_tabTitle(key) {
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`);
return el ? el.textContent.trim() : key;
@@ -1395,6 +1627,7 @@ const Studio = {
}
if (key === 'snapshots') this.loadSnapshots();
if (key === 'timeline') this.loadTimeline();
if (key === 'settings') this.loadSettings();
},
closeTab(key, ev) {
@@ -2088,6 +2321,7 @@ const Studio = {
include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
};
},