Phase 15: language + bias als Spalten, Filter, Edit-Form

Bisher waren die DB-Felder sources.language und sources.bias zwar gepflegt
(254/275 Quellen mit bias, 254 mit language), aber in der Verwaltung nicht
sichtbar. Der Admin konnte nicht filtern oder editieren.

Backend (routers/sources.py)
- GlobalSourceCreate + GlobalSourceUpdate Pydantic-Modelle: language +
  bias als Optional[str] erweitert (max 100 / 500 Zeichen).
- SOURCE_UPDATE_COLUMNS: language + bias hinzu.
- INSERT in create_global_source: schreibt language + bias mit.
- Neuer Endpoint GET /api/sources/global/languages: distinct language-Werte
  fuer Frontend-Filter-Dropdown.

Frontend HTML (dashboard.html)
- Grundquellen-Filter-Bar: Sprachen-Dropdown ergaenzt.
- Grundquellen-Tabellenkopf: 2 neue Spalten Sprache (sortable) + Bias.
- modalSource: 2 neue Felder language (mit datalist Vorschlaegen) + bias.
- Kundenquellen-Filter-Bar: Sprachen-Dropdown.
- Kundenquellen-Tabellenkopf: Sprache (sortable) + Bias.

Frontend JS (sources.js)
- loadGlobalSources lädt /languages parallel zu /global + /global/stats,
  populiert beide Sprache-Dropdowns + datalist im Edit-Modal.
- renderGlobalSources: cols 11 -> 13, language+bias-Zellen
  (Bias mit Tooltip fuer Lang-Texte).
- filterGlobalSources: Sprache-Filter, Bias in Suche.
- editGlobalSource: language + bias laden.
- Form-Submit: language + bias mitgesendet.
- renderTenantSources: cols 8 -> 10, language+bias-Zellen.
- tenantFilters um language erweitert, applyTenantFilterAndSort prueft.

Cache-Buster ?v=20260509 (heute) bleibt - Tag wechselt erst morgen.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 04:35:08 +00:00
Ursprung ff83f64aa6
Commit c86b2a0056
3 geänderte Dateien mit 91 neuen und 14 gelöschten Zeilen

Datei anzeigen

@@ -4,7 +4,7 @@
let globalSourcesCache = [];
let tenantSourcesCache = [];
// Phase 3c: Tenant-Tab State
let tenantFilters = { search: "", type: "", category: "", org: "" };
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
let tenantSort = { field: "org_name", asc: true };
let tenantSelected = new Set();
@@ -50,11 +50,32 @@ async function loadGlobalSources() {
populateSelect(document.getElementById("globalFilterType"),
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
}
const [list, stats] = await Promise.all([
const [list, stats, languages] = await Promise.all([
API.get("/api/sources/global"),
API.get("/api/sources/global/stats"),
API.get("/api/sources/global/languages").catch(() => []),
]);
globalSourcesCache = list;
populateSelect(
document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
populateSelect(
document.getElementById("tenantFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
// datalist fuer Edit-Modal
const dl = document.getElementById("languageSuggestions");
if (dl) {
dl.innerHTML = "";
(languages || []).forEach(l => {
const o = document.createElement("option");
o.value = l;
dl.appendChild(o);
});
}
renderGlobalStats(stats);
renderGlobalSources(globalSourcesCache);
} catch (err) {
@@ -138,7 +159,7 @@ function renderGlobalStats(stats) {
function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 11;
const cols = 13;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
return;
@@ -178,6 +199,8 @@ function renderGlobalSources(sources) {
<td class="text-right">${s.article_count || 0}</td>
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td class="text-secondary">${lastSeen}</td>
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
@@ -213,11 +236,13 @@ function filterGlobalSources() {
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
let filtered = globalSourcesCache.filter((s) => {
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q))) return false;
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q))) return false;
if (typeFilter && s.source_type !== typeFilter) return false;
if (catFilter && s.category !== catFilter) return false;
if (statusFilter && s.status !== statusFilter) return false;
if (langFilter && s.language !== langFilter) return false;
return true;
});
@@ -270,6 +295,8 @@ function editGlobalSource(id) {
document.getElementById("sourceCategory").value = s.category;
document.getElementById("sourceStatus").value = s.status;
document.getElementById("sourceNotes").value = s.notes || "";
document.getElementById("sourceLanguage").value = s.language || "";
document.getElementById("sourceBias").value = s.bias || "";
openModal("modalSource");
}
@@ -295,6 +322,8 @@ function setupSourceForms() {
category: document.getElementById("sourceCategory").value,
status: document.getElementById("sourceStatus").value,
notes: document.getElementById("sourceNotes").value || null,
language: document.getElementById("sourceLanguage").value || null,
bias: document.getElementById("sourceBias").value || null,
};
try {
@@ -381,6 +410,7 @@ function applyTenantFilterAndSort() {
if (tenantFilters.type && s.source_type !== tenantFilters.type) return false;
if (tenantFilters.category && s.category !== tenantFilters.category) return false;
if (tenantFilters.org && s.org_name !== tenantFilters.org) return false;
if (tenantFilters.language && s.language !== tenantFilters.language) return false;
return true;
});
@@ -403,6 +433,7 @@ function filterTenantSources() {
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
tenantFilters.language = document.getElementById("tenantFilterLanguage")?.value || "";
applyTenantFilterAndSort();
}
@@ -463,7 +494,7 @@ async function bulkPromoteSelected() {
function renderTenantSources(sources) {
const tbody = document.getElementById("tenantSourceTable");
const cols = 8;
const cols = 10;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
@@ -480,6 +511,8 @@ function renderTenantSources(sources) {
<td>${typeLabel(s.source_type)}</td>
<td>${categoryLabel(s.category)}</td>
<td>${esc(s.org_name || "-")}</td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td>${esc(s.added_by || "-")}</td>
<td>
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>