Feat: Grundquellen-Verwaltung und Kundenquellen-Übersicht

- Neuer Tab "Quellen" mit Sub-Tabs "Grundquellen" und "Kundenquellen"
- Grundquellen: CRUD (Erstellen, Bearbeiten, Löschen) - gilt für alle Monitore
- Kundenquellen: Übersicht aller tenant-spezifischen Quellen mit Org-Zuordnung
- Kundenquellen können zu Grundquellen befördert werden
- Suche/Filter in beiden Ansichten
- Sources-Router mit vollständiger API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-05 19:46:45 +01:00
Ursprung af6040cbf6
Commit 19fbf152eb
4 geänderte Dateien mit 547 neuen und 1 gelöschten Zeilen

242
src/static/js/sources.js Normale Datei
Datei anzeigen

@@ -0,0 +1,242 @@
/* Grundquellen & Kundenquellen Management */
"use strict";
let globalSourcesCache = [];
let tenantSourcesCache = [];
let editingSourceId = null;
const CATEGORY_LABELS = {
nachrichtenagentur: "Nachrichtenagentur",
"oeffentlich-rechtlich": "Öffentlich-Rechtlich",
qualitaetszeitung: "Qualitätszeitung",
behoerde: "Behörde",
fachmedien: "Fachmedien",
"think-tank": "Think-Tank",
international: "International",
regional: "Regional",
boulevard: "Boulevard",
sonstige: "Sonstige",
};
const TYPE_LABELS = {
rss_feed: "RSS-Feed",
web_source: "Webquelle",
excluded: "Gesperrt",
};
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
setupSourceForms();
// Beim Tab-Wechsel auf "Quellen" laden
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => loadGlobalSources());
});
});
function setupSourceSubTabs() {
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const subtab = tab.dataset.subtab;
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadGlobalSources();
else if (subtab === "tenant-sources") loadTenantSources();
});
});
}
// --- Grundquellen ---
async function loadGlobalSources() {
try {
globalSourcesCache = await API.get("/api/sources/global");
renderGlobalSources(globalSourcesCache);
} catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err);
}
}
function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable");
if (sources.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Grundquellen</td></tr>';
return;
}
tbody.innerHTML = sources.map((s) => `
<tr>
<td>${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td>
<td>${TYPE_LABELS[s.source_type] || s.source_type}</td>
<td>${CATEGORY_LABELS[s.category] || s.category}</td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Loeschen</button>
</td>
</tr>
`).join("");
document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
}
// Suche
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch");
if (el) {
el.addEventListener("input", () => {
const q = el.value.toLowerCase();
const filtered = globalSourcesCache.filter((s) =>
s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.category || "").toLowerCase().includes(q)
);
renderGlobalSources(filtered);
});
}
});
// --- Grundquelle erstellen/bearbeiten ---
function openNewGlobalSource() {
editingSourceId = null;
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
document.getElementById("sourceForm").reset();
openModal("modalSource");
}
function editGlobalSource(id) {
const s = globalSourcesCache.find((x) => x.id === id);
if (!s) return;
editingSourceId = id;
document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
document.getElementById("sourceName").value = s.name;
document.getElementById("sourceUrl").value = s.url || "";
document.getElementById("sourceDomain").value = s.domain || "";
document.getElementById("sourceType").value = s.source_type;
document.getElementById("sourceCategory").value = s.category;
document.getElementById("sourceStatus").value = s.status;
document.getElementById("sourceNotes").value = s.notes || "";
openModal("modalSource");
}
function setupSourceForms() {
document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource);
document.getElementById("sourceForm").addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("sourceError");
errEl.style.display = "none";
const body = {
name: document.getElementById("sourceName").value,
url: document.getElementById("sourceUrl").value || null,
domain: document.getElementById("sourceDomain").value || null,
source_type: document.getElementById("sourceType").value,
category: document.getElementById("sourceCategory").value,
status: document.getElementById("sourceStatus").value,
notes: document.getElementById("sourceNotes").value || null,
};
try {
if (editingSourceId) {
await API.put("/api/sources/global/" + editingSourceId, body);
} else {
await API.post("/api/sources/global", body);
}
closeModal("modalSource");
loadGlobalSources();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = "block";
}
});
// Domain aus URL ableiten
document.getElementById("sourceUrl").addEventListener("blur", (e) => {
const domainField = document.getElementById("sourceDomain");
if (domainField.value) return;
try {
const url = new URL(e.target.value);
domainField.value = url.hostname.replace(/^www\./, "");
} catch (_) {}
});
}
function confirmDeleteGlobalSource(id, name) {
showConfirm(
"Grundquelle loeschen",
`Soll die Grundquelle "${name}" endgueltig geloescht werden? Sie wird fuer alle Monitore entfernt.`,
async () => {
try {
await API.del("/api/sources/global/" + id);
loadGlobalSources();
} catch (err) {
alert(err.message);
}
}
);
}
// --- Kundenquellen ---
async function loadTenantSources() {
try {
tenantSourcesCache = await API.get("/api/sources/tenant");
renderTenantSources(tenantSourcesCache);
} catch (err) {
console.error("Kundenquellen laden fehlgeschlagen:", err);
}
}
function renderTenantSources(sources) {
const tbody = document.getElementById("tenantSourceTable");
if (sources.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Kundenquellen</td></tr>';
return;
}
tbody.innerHTML = sources.map((s) => `
<tr>
<td>${esc(s.name)}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.domain || "-")}</td>
<td>${TYPE_LABELS[s.source_type] || s.source_type}</td>
<td>${CATEGORY_LABELS[s.category] || s.category}</td>
<td>${esc(s.org_name || "-")}</td>
<td>${esc(s.added_by || "-")}</td>
<td>
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Uebernehmen</button>
</td>
</tr>
`).join("");
document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`;
}
// Suche Kundenquellen
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("tenantSourceSearch");
if (el) {
el.addEventListener("input", () => {
const q = el.value.toLowerCase();
const filtered = tenantSourcesCache.filter((s) =>
s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q)
);
renderTenantSources(filtered);
});
}
});
function promoteSource(id, name) {
showConfirm(
"Zur Grundquelle machen",
`Soll "${name}" als Grundquelle uebernommen werden? Sie wird dann fuer alle Monitore verfuegbar.`,
async () => {
try {
await API.post("/api/sources/tenant/" + id + "/promote");
loadTenantSources();
} catch (err) {
alert(err.message);
}
}
);
}