feat(ui): Sammelaktionen auch in der klassischen Ansicht, mit Lucide-Icons

Im Studio lassen sich mehrere Faelle auf einmal archivieren oder loeschen, in
der klassischen Ansicht ging das nur einzeln ueber die Aktionsleiste. Wer dort
zehn alte Lagen wegraeumen wollte, musste jede einzeln oeffnen.

Die Seitenleiste bekommt deshalb dieselbe Bedienung wie das Studio. Unten
stehen zwei Knoepfe, Archivieren und Loeschen. Der erste Klick schaltet den
Auswahlmodus ein, erst dann erscheinen Haken in der Lagenliste, an Stelle des
Statuspunktes. Ein Klick auf eine Zeile waehlt dann aus, statt die Lage zu
oeffnen. Bestaetigt wird in der Leiste darunter, die mitzaehlt und nur
anbietet, was zur Auswahl passt. Sind nur aktive Lagen gewaehlt, erscheint
kein Aktivieren-Knopf, im Loeschmodus keiner der beiden anderen.

"Alle sichtbaren" beruecksichtigt den Filter der Seitenleiste, waehlt also bei
aktivem Eigene-Filter nur die eigenen Lagen aus. Das Loeschen fragt vorher nach
und nennt Titel und Artikelzahl, weil es nicht rueckgaengig zu machen ist. Die
Aktionen laufen nacheinander ueber die normalen Endpunkte, damit deren
Rechtepruefung greift, und Fehlschlaege werden mit Nummer gemeldet statt still
verschluckt. War die offene Lage dabei, springt die Ansicht zurueck.

Dazu Lucide-Icons fuer Archivieren, Aktivieren, Loeschen und Abbrechen, in
beiden Ansichten. Im Studio setzte _renderBulkBar die Beschriftung bisher ueber
textContent des Knopfes, was das Icon bei jedem Neuzeichnen entfernt haette.
Die Beschriftung liegt jetzt in einer eigenen span.

Cache-Buster auf 20260802a, sonst laden Browser die alten Skripte weiter.

Neu sind 34 Pruefungen, sie laufen ohne Browser gegen ein DOM-Double.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-08-02 12:19:52 +00:00
Ursprung e440ef93d1
Commit c09c2a49b9
10 geänderte Dateien mit 574 neuen und 20 gelöschten Zeilen

Datei anzeigen

@@ -383,6 +383,10 @@
.btn-bulk { .btn-bulk {
flex: 1; flex: 1;
min-width: 84px; min-width: 84px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px; padding: 6px 10px;
border-radius: var(--radius-md, 6px); border-radius: var(--radius-md, 6px);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -394,6 +398,7 @@
cursor: pointer; cursor: pointer;
transition: border-color .15s, background .15s, color .15s; transition: border-color .15s, background .15s, color .15s;
} }
.btn-bulk svg { flex-shrink: 0; }
.btn-bulk:hover { border-color: var(--accent); color: var(--accent); } .btn-bulk:hover { border-color: var(--accent); color: var(--accent); }
.btn-bulk[hidden] { display: none; } .btn-bulk[hidden] { display: none; }
.btn-bulk.danger { color: var(--danger, #EF4444); } .btn-bulk.danger { color: var(--danger, #EF4444); }

Datei anzeigen

@@ -3277,6 +3277,106 @@ body.first-refresh-blocking .header {
} }
/* Sidebar Sources Link */ /* Sidebar Sources Link */
/* === Sammelaktionen in der Seitenleiste ============================== */
/* Zwei Zustaende. Normal stehen dort nur die beiden Startknoepfe, im
Auswahlmodus die Bestaetigungsleiste. Beides sitzt ueber dem Fussbereich
mit Quellen und Feedback. */
.sidebar-bulk {
padding: var(--sp-md) var(--sp-xl) 0;
display: flex;
gap: var(--sp-md);
}
.sidebar-bulk[hidden] {
display: none;
}
.sidebar-bulk-btn {
flex: 1;
min-height: 32px;
font-size: 12px;
font-weight: 500;
padding: 0 var(--sp-md);
}
.sidebar-bulk-btn svg {
flex-shrink: 0;
}
.sidebar-bulk-btn.danger {
color: var(--error);
}
.sidebar-bulk-btn.danger:hover:not(:disabled) {
background: var(--tint-error);
border-color: var(--error);
}
.sidebar-bulk-btn.ghost {
color: var(--text-tertiary);
}
.sidebar-bulk-btn[hidden] {
display: none;
}
/* Bestaetigungsleiste: untereinander, damit die Zaehlung lesbar bleibt */
.sidebar-bulk-active {
flex-direction: column;
align-items: stretch;
gap: var(--sp-md);
padding-bottom: var(--sp-md);
border-top: 1px solid var(--accent);
padding-top: var(--sp-md);
}
.sidebar-bulk-count {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.sidebar-bulk-all {
display: flex;
align-items: center;
gap: var(--sp-md);
font-size: 12px;
color: var(--text-tertiary);
cursor: pointer;
}
.sidebar-bulk-all input {
accent-color: var(--accent);
cursor: pointer;
}
.sidebar-bulk-actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-md);
}
/* Haken in der Lagenliste, nur im Auswahlmodus sichtbar */
.incident-check {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
}
.incident-check input {
accent-color: var(--accent);
cursor: pointer;
margin: 0;
}
/* Ausgewaehlte Lage: vollstaendig umlaufender Rahmen, kein einseitiger Streifen */
.incident-item.picked {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-radius: var(--radius);
}
.sidebar-sources-link { .sidebar-sources-link {
padding: var(--sp-lg) var(--sp-xl); padding: var(--sp-lg) var(--sp-xl);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css"> <link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260723a"> <link rel="stylesheet" href="/static/css/style.css?v=20260802a">
<style> <style>
/* Export Modal Radio */ /* Export Modal Radio */
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; } .export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
@@ -123,6 +123,46 @@
</h2> </h2>
<div id="archived-incidents" aria-live="polite" style="display:none;"></div> <div id="archived-incidents" aria-live="polite" style="display:none;"></div>
</div> </div>
<!-- Sammelaktionen. Der erste Klick schaltet den Auswahlmodus ein,
erst dann erscheinen die Haken in der Lagenliste. -->
<div class="sidebar-bulk" id="sidebar-bulk-tools">
<button class="btn btn-secondary btn-small sidebar-bulk-btn" onclick="App.startBulkMode('archive')" title="Mehrere Lagen archivieren oder wieder aktivieren" data-i18n-attr="title:sidebar.bulk_archive_title">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>
<span data-i18n="action.archive">Archivieren</span>
</button>
<button class="btn btn-secondary btn-small sidebar-bulk-btn danger" onclick="App.startBulkMode('delete')" title="Mehrere Lagen löschen" data-i18n-attr="title:sidebar.bulk_delete_title">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<span data-i18n="action.delete">Löschen</span>
</button>
</div>
<!-- Auswahlmodus. Hier wird die Sammelaktion bestätigt oder verworfen. -->
<div class="sidebar-bulk sidebar-bulk-active" id="sidebar-bulk-bar" hidden>
<div class="sidebar-bulk-count" id="sidebar-bulk-count" aria-live="polite"></div>
<label class="sidebar-bulk-all">
<input type="checkbox" id="sidebar-bulk-all" onchange="App.selectAllVisible(this.checked)">
<span data-i18n="sidebar.bulk_all">Alle sichtbaren</span>
</label>
<div class="sidebar-bulk-actions">
<button class="btn btn-secondary btn-small sidebar-bulk-btn" id="sidebar-bulk-archive" onclick="App.bulkStatus('archived')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>
<span>Archivieren</span>
</button>
<button class="btn btn-secondary btn-small sidebar-bulk-btn" id="sidebar-bulk-activate" onclick="App.bulkStatus('active')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>
<span>Aktivieren</span>
</button>
<button class="btn btn-secondary btn-small sidebar-bulk-btn danger" id="sidebar-bulk-delete" onclick="App.bulkDelete()">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<span>Löschen</span>
</button>
<button class="btn btn-secondary btn-small sidebar-bulk-btn ghost" onclick="App.endBulkMode()" title="Auswahl verwerfen">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span data-i18n="action.cancel">Abbrechen</span>
</button>
</div>
</div>
<div class="sidebar-sources-link"> <div class="sidebar-sources-link">
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten" data-i18n-attr="title:sidebar.manage_sources_title"> <button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten" data-i18n-attr="title:sidebar.manage_sources_title">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>
@@ -170,8 +210,8 @@
<button class="btn btn-primary btn-small" id="refresh-btn" data-i18n="action.refresh">Aktualisieren</button> <button class="btn btn-primary btn-small" id="refresh-btn" data-i18n="action.refresh">Aktualisieren</button>
<button class="btn btn-secondary btn-small" id="edit-incident-btn" data-i18n="action.edit">Bearbeiten</button> <button class="btn btn-secondary btn-small" id="edit-incident-btn" data-i18n="action.edit">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="App.openExportModal()" data-i18n="action.export">Bericht exportieren</button> <button class="btn btn-secondary btn-small" onclick="App.openExportModal()" data-i18n="action.export">Bericht exportieren</button>
<button class="btn btn-secondary btn-small" id="archive-incident-btn" data-i18n="action.archive">Archivieren</button> <button class="btn btn-secondary btn-small" id="archive-incident-btn" title="Diese Lage archivieren"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg><span data-i18n="action.archive">Archivieren</span></button>
<button class="btn btn-danger btn-small" id="delete-incident-btn" data-i18n="action.delete">Löschen</button> <button class="btn btn-danger btn-small" id="delete-incident-btn" title="Diese Lage löschen"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg><span data-i18n="action.delete">Löschen</span></button>
</div> </div>
</div> </div>
<div class="incident-header-row2"> <div class="incident-header-row2">
@@ -818,11 +858,11 @@
<script src="/static/js/i18n.js?v=20260513a"></script> <script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260725b"></script> <script src="/static/js/api.js?v=20260725b"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260731a"></script> <script src="/static/js/components.js?v=20260802a"></script>
<script src="/static/js/layout.js?v=20260513f"></script> <script src="/static/js/layout.js?v=20260513f"></script>
<script src="/static/js/pipeline.js?v=20260513d"></script> <script src="/static/js/pipeline.js?v=20260513d"></script>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260731a"></script> <script src="/static/js/app.js?v=20260802a"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script> <script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script> <script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260514e"></script> <script src="/static/js/chat.js?v=20260514e"></script>

Datei anzeigen

@@ -3,6 +3,9 @@
"sidebar.research": "Recherchen", "sidebar.research": "Recherchen",
"sidebar.archive": "Archiv", "sidebar.archive": "Archiv",
"sidebar.sources": "Quellen", "sidebar.sources": "Quellen",
"sidebar.bulk_all": "Alle sichtbaren",
"sidebar.bulk_archive_title": "Mehrere Lagen archivieren oder wieder aktivieren",
"sidebar.bulk_delete_title": "Mehrere Lagen löschen",
"sidebar.feedback": "Feedback", "sidebar.feedback": "Feedback",
"sidebar.manage_sources_title": "Quellen verwalten", "sidebar.manage_sources_title": "Quellen verwalten",
"sidebar.feedback_title": "Feedback senden", "sidebar.feedback_title": "Feedback senden",
@@ -16,6 +19,7 @@
"action.edit": "Bearbeiten", "action.edit": "Bearbeiten",
"action.export": "Bericht exportieren", "action.export": "Bericht exportieren",
"action.archive": "Archivieren", "action.archive": "Archivieren",
"action.cancel": "Abbrechen",
"action.delete": "Löschen", "action.delete": "Löschen",
"action.refreshing": "Läuft...", "action.refreshing": "Läuft...",
"action.restore": "Wiederherstellen", "action.restore": "Wiederherstellen",

Datei anzeigen

@@ -3,6 +3,9 @@
"sidebar.research": "Research", "sidebar.research": "Research",
"sidebar.archive": "Archive", "sidebar.archive": "Archive",
"sidebar.sources": "Sources", "sidebar.sources": "Sources",
"sidebar.bulk_all": "All visible",
"sidebar.bulk_archive_title": "Archive or reactivate several cases",
"sidebar.bulk_delete_title": "Delete several cases",
"sidebar.feedback": "Feedback", "sidebar.feedback": "Feedback",
"sidebar.manage_sources_title": "Manage sources", "sidebar.manage_sources_title": "Manage sources",
"sidebar.feedback_title": "Send feedback", "sidebar.feedback_title": "Send feedback",
@@ -16,6 +19,7 @@
"action.edit": "Edit", "action.edit": "Edit",
"action.export": "Export report", "action.export": "Export report",
"action.archive": "Archive", "action.archive": "Archive",
"action.cancel": "Cancel",
"action.delete": "Delete", "action.delete": "Delete",
"action.refreshing": "Running...", "action.refreshing": "Running...",
"action.restore": "Restore", "action.restore": "Restore",

Datei anzeigen

@@ -544,6 +544,150 @@ const App = {
} }
}, },
// === Sammelaktionen in der Seitenleiste ===
// Die Haken sind normalerweise weg. Erst ein Klick auf Archivieren oder
// Loeschen unten in der Seitenleiste schaltet den Auswahlmodus ein. Das
// entspricht dem Verhalten im Studio, damit beide Ansichten sich gleich
// bedienen lassen.
_bulkMode: null,
_bulkSel: new Set(),
startBulkMode(mode) {
this._bulkMode = mode;
this._bulkSel.clear();
this.renderSidebar();
},
endBulkMode() {
this._bulkMode = null;
this._bulkSel.clear();
const alle = document.getElementById('sidebar-bulk-all');
if (alle) alle.checked = false;
this.renderSidebar();
},
toggleBulkSel(id, on) {
if (on) this._bulkSel.add(id); else this._bulkSel.delete(id);
this.renderSidebar();
},
// Sichtbar heisst: was der aktive Filter der Seitenleiste gerade zeigt.
_visibleIncidents() {
let sichtbar = this.incidents || [];
if (this._sidebarFilter === 'mine') {
sichtbar = sichtbar.filter(i => i.created_by_username === this._currentUsername);
}
return sichtbar;
},
selectAllVisible(on) {
const sichtbar = this._visibleIncidents();
if (on) sichtbar.forEach(i => this._bulkSel.add(i.id));
else sichtbar.forEach(i => this._bulkSel.delete(i.id));
this.renderSidebar();
},
_renderBulkBar() {
const mode = this._bulkMode;
const tools = document.getElementById('sidebar-bulk-tools');
const bar = document.getElementById('sidebar-bulk-bar');
if (tools) tools.hidden = mode !== null;
if (bar) bar.hidden = mode === null;
if (mode === null) return;
const gewaehlt = (this.incidents || []).filter(i => this._bulkSel.has(i.id));
const n = gewaehlt.length;
const aktive = gewaehlt.filter(i => i.status === 'active').length;
const archivierte = n - aktive;
const zaehler = document.getElementById('sidebar-bulk-count');
if (zaehler) {
zaehler.textContent = n === 0
? (mode === 'delete' ? 'Lagen zum Löschen anhaken' : 'Lagen zum Archivieren anhaken')
: (n === 1 ? '1 Lage ausgewählt' : n + ' Lagen ausgewählt');
}
// Nur anbieten, was zum Modus und zur Auswahl passt.
const knopfArchiv = document.getElementById('sidebar-bulk-archive');
const knopfAktiv = document.getElementById('sidebar-bulk-activate');
const knopfLoesch = document.getElementById('sidebar-bulk-delete');
if (knopfArchiv) {
knopfArchiv.hidden = mode !== 'archive' || aktive === 0;
const t = knopfArchiv.querySelector('span');
if (t) t.textContent = 'Archivieren (' + aktive + ')';
}
if (knopfAktiv) {
knopfAktiv.hidden = mode !== 'archive' || archivierte === 0;
const t = knopfAktiv.querySelector('span');
if (t) t.textContent = 'Aktivieren (' + archivierte + ')';
}
if (knopfLoesch) {
knopfLoesch.hidden = mode !== 'delete' || n === 0;
const t = knopfLoesch.querySelector('span');
if (t) t.textContent = 'Löschen (' + n + ')';
}
const sichtbar = this._visibleIncidents();
const alle = document.getElementById('sidebar-bulk-all');
if (alle) alle.checked = sichtbar.length > 0 && sichtbar.every(i => this._bulkSel.has(i.id));
},
// Die Sammelaktionen laufen nacheinander ueber die normalen Endpunkte, damit
// deren Rechtepruefung greift. Fehlschlaege werden gezaehlt statt still
// verschluckt.
async _bulkRun(ids, fn, verb) {
let ok = 0;
const fehlgeschlagen = [];
for (const id of ids) {
try { await fn(id); ok++; }
catch (e) { fehlgeschlagen.push(id); }
}
this._bulkSel.clear();
this._bulkMode = null;
await this.loadIncidents();
if (fehlgeschlagen.length) {
UI.showToast(ok + ' ' + verb + ', ' + fehlgeschlagen.length
+ ' fehlgeschlagen (IDs ' + fehlgeschlagen.join(', ') + ')', 'error');
} else {
UI.showToast(ok + ' ' + (ok === 1 ? 'Lage' : 'Lagen') + ' ' + verb, 'success');
}
},
async bulkStatus(status) {
const ids = (this.incidents || [])
.filter(i => this._bulkSel.has(i.id)
&& (status === 'archived' ? i.status === 'active' : i.status !== 'active'))
.map(i => i.id);
if (!ids.length) return;
const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert';
await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb);
},
async bulkDelete() {
const gewaehlt = (this.incidents || []).filter(i => this._bulkSel.has(i.id));
if (!gewaehlt.length) return;
const artikel = gewaehlt.reduce((n, i) => n + (i.article_count || 0), 0);
const namen = gewaehlt.slice(0, 8).map(i => '\n\u2022 ' + i.title).join('')
+ (gewaehlt.length > 8 ? '\n\u2022 … und ' + (gewaehlt.length - 8) + ' weitere' : '');
const frage = gewaehlt.length + ' ' + (gewaehlt.length === 1 ? 'Lage' : 'Lagen')
+ ' endgültig löschen?' + namen
+ '\n\nDamit gehen ' + artikel + ' Artikel samt Faktenchecks, Lageberichten'
+ ' und Karten verloren. Das lässt sich nicht rückgängig machen.'
+ '\n\nWenn du sie nur aus der Liste nehmen willst, nutze stattdessen Archivieren.';
if (!confirm(frage)) return;
const ids = gewaehlt.map(i => i.id);
const offeneDabei = this.currentIncidentId && ids.includes(this.currentIncidentId);
await this._bulkRun(ids, (id) => API.deleteIncident(id), 'gelöscht');
if (offeneDabei) {
this.currentIncidentId = null;
const leer = document.getElementById('empty-state');
const inhalt = document.getElementById('incident-view');
if (leer) leer.style.display = 'flex';
if (inhalt) inhalt.style.display = 'none';
}
},
renderSidebar() { renderSidebar() {
const activeContainer = document.getElementById('active-incidents'); const activeContainer = document.getElementById('active-incidents');
const researchContainer = document.getElementById('active-research'); const researchContainer = document.getElementById('active-research');
@@ -597,6 +741,9 @@ const App = {
// Sidebar-Stats aktualisieren // Sidebar-Stats aktualisieren
this.updateSidebarStats(); this.updateSidebarStats();
// Zustand der Sammelaktionen nachziehen
this._renderBulkBar();
}, },
setSidebarFilter(filter) { setSidebarFilter(filter) {

Datei anzeigen

@@ -59,9 +59,20 @@ const UI = {
} }
} }
// Im Auswahlmodus der Seitenleiste tritt ein Haken an die Stelle des
// Statuspunktes und ein Klick waehlt aus, statt die Lage zu oeffnen.
const auswahl = App._bulkMode != null;
const gewaehlt = auswahl && App._bulkSel.has(incident.id);
const klick = auswahl
? `App.toggleBulkSel(${incident.id}, !App._bulkSel.has(${incident.id}))`
: `App.selectIncident(${incident.id})`;
const markierung = auswahl
? `<label class="incident-check" title="Auswählen" onclick="event.stopPropagation()"><input type="checkbox" ${gewaehlt ? 'checked' : ''} onchange="App.toggleBulkSel(${incident.id}, this.checked)"></label>`
: `<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>`;
return ` return `
<div class="incident-item ${activeClass}${refreshClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0"> <div class="incident-item ${activeClass}${refreshClass}${gewaehlt ? ' picked' : ''}" data-id="${incident.id}" onclick="${klick}" role="button" tabindex="0">
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span> ${markierung}
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div class="incident-name">${this.escape(incident.title)}</div> <div class="incident-name">${this.escape(incident.title)}</div>
<div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}${incident.ai_backend === 'bedrock' ? ' &middot; <span class="sidebar-ai-tag ai-eu">EU</span>' : (incident.ai_backend === 'cli' ? ' &middot; <span class="sidebar-ai-tag ai-anthropic">Anthropic</span>' : '')}</div> <div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}${incident.ai_backend === 'bedrock' ? ' &middot; <span class="sidebar-ai-tag ai-eu">EU</span>' : (incident.ai_backend === 'cli' ? ' &middot; <span class="sidebar-ai-tag ai-anthropic">Anthropic</span>' : '')}</div>

Datei anzeigen

@@ -459,9 +459,15 @@ const Studio = {
const ba = document.getElementById('bulk-archive'); const ba = document.getElementById('bulk-archive');
const bv = document.getElementById('bulk-activate'); const bv = document.getElementById('bulk-activate');
const bd = document.getElementById('bulk-delete'); const bd = document.getElementById('bulk-delete');
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; ba.textContent = `Archivieren (${sel.filter(i => i.status === 'active').length})`; } // Beschriftung in die span, sonst wuerde textContent das Icon entfernen.
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; bv.textContent = `Aktivieren (${sel.filter(i => i.status !== 'active').length})`; } const setzeText = (el, text) => {
if (bd) { bd.hidden = mode !== 'delete' || n === 0; bd.textContent = `Löschen (${n})`; } if (!el) return;
const ziel = el.querySelector('span');
if (ziel) ziel.textContent = text; else el.textContent = text;
};
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; setzeText(ba, `Archivieren (${sel.filter(i => i.status === 'active').length})`); }
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; setzeText(bv, `Aktivieren (${sel.filter(i => i.status !== 'active').length})`); }
if (bd) { bd.hidden = mode !== 'delete' || n === 0; setzeText(bd, `Löschen (${n})`); }
// "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist // "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist
const vis = this._visibleCases(); const vis = this._visibleCases();

Datei anzeigen

@@ -12,8 +12,8 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css"> <link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260501h"> <link rel="stylesheet" href="/static/css/style.css?v=20260802a">
<link rel="stylesheet" href="/static/css/studio.css?v=20260725a"> <link rel="stylesheet" href="/static/css/studio.css?v=20260802a">
</head> </head>
<body> <body>
<div class="studio"> <div class="studio">
@@ -126,8 +126,8 @@
<!-- Standardleiste: startet den Auswahlmodus (ohne Haken in der Liste) --> <!-- Standardleiste: startet den Auswahlmodus (ohne Haken in der Liste) -->
<div class="bulk-bar" id="case-tools"> <div class="bulk-bar" id="case-tools">
<div class="bulk-actions"> <div class="bulk-actions">
<button class="btn-bulk" onclick="Studio.startCaseMode('archive')" title="Mehrere Fälle archivieren oder reaktivieren">Archivieren</button> <button class="btn-bulk" onclick="Studio.startCaseMode('archive')" title="Mehrere Fälle archivieren oder reaktivieren"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg><span>Archivieren</span></button>
<button class="btn-bulk danger" onclick="Studio.startCaseMode('delete')" title="Mehrere Fälle löschen">Löschen</button> <button class="btn-bulk danger" onclick="Studio.startCaseMode('delete')" title="Mehrere Fälle löschen"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg><span>Löschen</span></button>
</div> </div>
</div> </div>
@@ -135,10 +135,10 @@
<div class="bulk-bar" id="bulk-bar" hidden> <div class="bulk-bar" id="bulk-bar" hidden>
<span class="bulk-count" id="bulk-count"></span> <span class="bulk-count" id="bulk-count"></span>
<div class="bulk-actions"> <div class="bulk-actions">
<button class="btn-bulk" id="bulk-archive" onclick="Studio.bulkStatus('archived')">Archivieren</button> <button class="btn-bulk" id="bulk-archive" onclick="Studio.bulkStatus('archived')"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg><span>Archivieren</span></button>
<button class="btn-bulk" id="bulk-activate" onclick="Studio.bulkStatus('active')">Aktivieren</button> <button class="btn-bulk" id="bulk-activate" onclick="Studio.bulkStatus('active')"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg><span>Aktivieren</span></button>
<button class="btn-bulk danger" id="bulk-delete" onclick="Studio.bulkDelete()">Löschen</button> <button class="btn-bulk danger" id="bulk-delete" onclick="Studio.bulkDelete()"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg><span>Löschen</span></button>
<button class="btn-bulk ghost" onclick="Studio.endCaseMode()" title="Auswahl verwerfen">Abbrechen</button> <button class="btn-bulk ghost" onclick="Studio.endCaseMode()" title="Auswahl verwerfen"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg><span>Abbrechen</span></button>
</div> </div>
</div> </div>
</div> </div>
@@ -563,9 +563,9 @@
<script src="/static/js/i18n.js?v=20260513a"></script> <script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260725b"></script> <script src="/static/js/api.js?v=20260725b"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260514e"></script> <script src="/static/js/components.js?v=20260802a"></script>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/ai-disclaimer.js?v=20260725a"></script> <script src="/static/js/ai-disclaimer.js?v=20260725a"></script>
<script src="/static/js/studio.js?v=20260725a"></script> <script src="/static/js/studio.js?v=20260802a"></script>
</body> </body>
</html> </html>

237
tests/test_sammelaktionen.js Normale Datei
Datei anzeigen

@@ -0,0 +1,237 @@
/**
* Testet die Sammelaktionen der Seitenleiste ohne Browser.
*
* Die Methoden werden aus app.js herausgeloest und gegen ein schlankes
* DOM-Double laufen gelassen. Kein Netzzugriff, keine Kosten.
*
* Aufruf aus dem Projektstamm:
* node tests/test_sammelaktionen.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let ok = 0;
let fail = 0;
function pruefe(name, bedingung, extra) {
if (bedingung) {
ok++;
console.log(' OK ' + name);
} else {
fail++;
console.log(' FEHL ' + name + ' '
+ (extra === undefined ? '' : JSON.stringify(extra)));
}
}
// --- DOM-Double, nur was die Sammelaktionen anfassen ---
function element(id) {
return {
id: id,
hidden: false,
checked: false,
textContent: '',
style: {},
_span: { textContent: '' },
querySelector(sel) { return sel === 'span' ? this._span : null; },
};
}
const elemente = {};
for (const id of ['sidebar-bulk-tools', 'sidebar-bulk-bar', 'sidebar-bulk-count',
'sidebar-bulk-all', 'sidebar-bulk-archive', 'sidebar-bulk-activate',
'sidebar-bulk-delete', 'empty-state', 'incident-view']) {
elemente[id] = element(id);
}
const meldungen = [];
const aufrufe = { update: [], delete: [] };
const umgebung = {
document: { getElementById: (id) => elemente[id] || null },
UI: { showToast: (text, art) => meldungen.push({ text, art }) },
API: {
updateIncident: async (id, daten) => { aufrufe.update.push({ id, daten }); },
deleteIncident: async (id) => { aufrufe.delete.push(id); },
},
confirm: () => umgebung._bestaetigen,
_bestaetigen: true,
console: console,
};
umgebung.window = umgebung;
// --- Die Sammelaktionen aus app.js herausloesen ---
const quelle = fs.readFileSync(
path.join(__dirname, '..', 'src', 'static', 'js', 'app.js'), 'utf8');
const von = quelle.indexOf(' // === Sammelaktionen in der Seitenleiste ===');
const bis = quelle.indexOf(' renderSidebar() {', von);
if (von === -1 || bis === -1) {
console.log('FEHLER: Sammelaktionen in app.js nicht gefunden');
process.exit(1);
}
const kontext = vm.createContext(umgebung);
vm.runInContext('var App = {\n' + quelle.slice(von, bis) + '\n'
+ ' incidents: [], currentIncidentId: null, _sidebarFilter: "all",'
+ ' _currentUsername: "ich",\n'
+ ' renderSidebar() { this._renderBulkBar(); },\n'
+ ' async loadIncidents() { this._geladen = (this._geladen || 0) + 1; },\n'
+ '};', kontext);
const App = kontext.App;
const LAGEN = [
{ id: 1, title: 'Ceuta', status: 'active', article_count: 40, created_by_username: 'ich' },
{ id: 2, title: 'IMK 2026', status: 'active', article_count: 20, created_by_username: 'wer' },
{ id: 3, title: 'Altfall', status: 'archived', article_count: 5, created_by_username: 'ich' },
];
function neu() {
App.incidents = LAGEN.map(l => Object.assign({}, l));
App._bulkMode = null;
App._bulkSel = new Set();
App._sidebarFilter = 'all';
App.currentIncidentId = null;
aufrufe.update.length = 0;
aufrufe.delete.length = 0;
meldungen.length = 0;
umgebung._bestaetigen = true;
for (const el of Object.values(elemente)) {
el.hidden = false;
el.checked = false;
el.textContent = '';
el.style = {};
el._span.textContent = '';
}
}
async function main() {
console.log('\nA) Der Auswahlmodus schaltet die Leisten um');
neu();
pruefe('A1 normal ist der Auswahlmodus aus', App._bulkMode === null);
App.startBulkMode('archive');
pruefe('A2 Startleiste verschwindet', elemente['sidebar-bulk-tools'].hidden === true);
pruefe('A3 Bestaetigungsleiste erscheint', elemente['sidebar-bulk-bar'].hidden === false);
pruefe('A4 Hinweis fordert zum Anhaken auf',
elemente['sidebar-bulk-count'].textContent.includes('anhaken'),
elemente['sidebar-bulk-count'].textContent);
App.endBulkMode();
pruefe('A5 Abbrechen stellt den Ausgangszustand her',
App._bulkMode === null && elemente['sidebar-bulk-tools'].hidden === false);
pruefe('A6 Auswahl wird dabei verworfen', App._bulkSel.size === 0);
console.log('\nB) Angeboten wird nur, was zur Auswahl passt');
neu();
App.startBulkMode('archive');
App.toggleBulkSel(1, true);
pruefe('B1 Archivieren sichtbar bei aktiver Lage',
elemente['sidebar-bulk-archive'].hidden === false);
pruefe('B2 Aktivieren verborgen ohne archivierte Lage',
elemente['sidebar-bulk-activate'].hidden === true);
pruefe('B3 Loeschen im Archivmodus verborgen',
elemente['sidebar-bulk-delete'].hidden === true);
pruefe('B4 Zaehler im Knopf stimmt',
elemente['sidebar-bulk-archive']._span.textContent === 'Archivieren (1)',
elemente['sidebar-bulk-archive']._span.textContent);
App.toggleBulkSel(3, true);
pruefe('B5 Aktivieren erscheint bei archivierter Lage',
elemente['sidebar-bulk-activate'].hidden === false);
pruefe('B6 beide Zaehler stimmen',
elemente['sidebar-bulk-archive']._span.textContent === 'Archivieren (1)'
&& elemente['sidebar-bulk-activate']._span.textContent === 'Aktivieren (1)');
pruefe('B7 Auswahl wird gezaehlt',
elemente['sidebar-bulk-count'].textContent === '2 Lagen ausgewählt',
elemente['sidebar-bulk-count'].textContent);
neu();
App.startBulkMode('delete');
App.toggleBulkSel(1, true);
pruefe('B8 im Loeschmodus nur Loeschen',
elemente['sidebar-bulk-delete'].hidden === false
&& elemente['sidebar-bulk-archive'].hidden === true);
pruefe('B9 Zaehler im Loeschknopf',
elemente['sidebar-bulk-delete']._span.textContent === 'Löschen (1)',
elemente['sidebar-bulk-delete']._span.textContent);
console.log('\nC) Alle sichtbaren auswaehlen achtet auf den Filter');
neu();
App.startBulkMode('archive');
App.selectAllVisible(true);
pruefe('C1 ohne Filter sind alle drei gewaehlt', App._bulkSel.size === 3, App._bulkSel.size);
pruefe('C2 der Alle-Haken spiegelt das', elemente['sidebar-bulk-all'].checked === true);
App.selectAllVisible(false);
pruefe('C3 abwaehlen leert die Auswahl', App._bulkSel.size === 0);
App._sidebarFilter = 'mine';
App.selectAllVisible(true);
pruefe('C4 mit Filter nur die eigenen', App._bulkSel.size === 2, [...App._bulkSel]);
pruefe('C5 der fremde Fall fehlt', !App._bulkSel.has(2));
console.log('\nD) Archivieren wirkt nur auf aktive Lagen');
neu();
App.startBulkMode('archive');
App._bulkSel = new Set([1, 3]);
await App.bulkStatus('archived');
pruefe('D1 nur die aktive Lage wurde archiviert',
aufrufe.update.length === 1 && aufrufe.update[0].id === 1, aufrufe.update);
pruefe('D2 Status korrekt gesetzt',
aufrufe.update[0].daten.status === 'archived', aufrufe.update[0]);
pruefe('D3 Auswahlmodus endet danach', App._bulkMode === null);
pruefe('D4 Rueckmeldung an den Nutzer',
meldungen.length === 1 && meldungen[0].art === 'success', meldungen);
console.log('\nE) Aktivieren wirkt nur auf archivierte Lagen');
neu();
App.startBulkMode('archive');
App._bulkSel = new Set([1, 3]);
await App.bulkStatus('active');
pruefe('E1 nur die archivierte Lage wurde aktiviert',
aufrufe.update.length === 1 && aufrufe.update[0].id === 3, aufrufe.update);
console.log('\nF) Loeschen fragt nach und raeumt auf');
neu();
App.startBulkMode('delete');
App._bulkSel = new Set([1, 2]);
umgebung._bestaetigen = false;
await App.bulkDelete();
pruefe('F1 ohne Bestaetigung wird nichts geloescht', aufrufe.delete.length === 0);
pruefe('F2 der Auswahlmodus bleibt bestehen', App._bulkMode === 'delete');
umgebung._bestaetigen = true;
App.currentIncidentId = 1;
await App.bulkDelete();
pruefe('F3 beide Lagen geloescht',
aufrufe.delete.length === 2 && aufrufe.delete.includes(1), aufrufe.delete);
pruefe('F4 die offene Lage wird geschlossen', App.currentIncidentId === null);
pruefe('F5 die Leeransicht erscheint',
elemente['empty-state'].style.display === 'flex'
&& elemente['incident-view'].style.display === 'none',
[elemente['empty-state'].style.display, elemente['incident-view'].style.display]);
console.log('\nG) Fehlschlaege werden gemeldet statt verschluckt');
neu();
App.startBulkMode('archive');
App._bulkSel = new Set([1]);
const alteFunktion = umgebung.API.updateIncident;
umgebung.API.updateIncident = async () => { throw new Error('kein Recht'); };
await App.bulkStatus('archived');
umgebung.API.updateIncident = alteFunktion;
pruefe('G1 Fehlermeldung erscheint',
meldungen.length === 1 && meldungen[0].art === 'error', meldungen);
pruefe('G2 die betroffene Nummer steht darin',
meldungen[0].text.includes('1'), meldungen[0].text);
console.log('\nH) Leere Auswahl loest nichts aus');
neu();
App.startBulkMode('archive');
await App.bulkStatus('archived');
await App.bulkDelete();
pruefe('H1 keine Aufrufe ohne Auswahl',
aufrufe.update.length === 0 && aufrufe.delete.length === 0);
pruefe('H2 keine Rueckmeldung ohne Auswahl', meldungen.length === 0, meldungen);
console.log('\nErgebnis: ' + ok + ' bestanden, ' + fail + ' fehlgeschlagen');
process.exit(fail ? 1 : 0);
}
main();