Phase 3c: Kundenquellen-Tab mit Filter + Sort + Bulk-Promote
Backend
- routers/sources.py: POST /api/sources/tenant/bulk-promote NEU
Nimmt Liste von source_ids, promotet jede einzeln zur Grundquelle.
Returns {promoted, skipped[{id,name,reason}], failed[{id,error}]}.
Ueberspringt Quellen die schon Grundquellen sind oder deren URL bereits
als Grundquelle existiert.
Frontend
- dashboard.html sub-tenant-sources: action-bar erweitert um
3 Filter-Selects (Typ, Kategorie, Org), Bulk-Promote-Button.
Tabelle bekommt Checkbox-Spalte + sortable Spalten (Sort-Icons).
- sources.js: tenant-Tab komplett refactored
- State: tenantFilters, tenantSort, tenantSelected (Set)
- applyTenantFilterAndSort: zentraler Render-Pfad mit allen Filtern + Sort
- populateTenantFilters: Org-Liste aus Daten, Typ/Kategorie aus META
- toggleTenantSelect / toggleTenantSelectAll: Selection-Logik
- bulkPromoteSelected: showConfirm -> POST -> Toast mit Ergebnis
- renderTenantSources: Checkbox-Spalte, dynamische typeLabel/categoryLabel
- Counter zeigt jetzt N gefiltert / Gesamt
Dieser Commit ist enthalten in:
@@ -3,6 +3,11 @@
|
||||
|
||||
let globalSourcesCache = [];
|
||||
let tenantSourcesCache = [];
|
||||
// Phase 3c: Tenant-Tab State
|
||||
let tenantFilters = { search: "", type: "", category: "", org: "" };
|
||||
let tenantSort = { field: "org_name", asc: true };
|
||||
let tenantSelected = new Set();
|
||||
|
||||
let editingSourceId = null;
|
||||
let globalSortField = "category";
|
||||
let globalSortAsc = true;
|
||||
@@ -246,47 +251,160 @@ function confirmDeleteGlobalSource(id, name) {
|
||||
async function loadTenantSources() {
|
||||
try {
|
||||
tenantSourcesCache = await API.get("/api/sources/tenant");
|
||||
renderTenantSources(tenantSourcesCache);
|
||||
tenantSelected.clear();
|
||||
populateTenantFilters();
|
||||
applyTenantFilterAndSort();
|
||||
} catch (err) {
|
||||
console.error("Kundenquellen laden fehlgeschlagen:", err);
|
||||
showToast("Kundenquellen konnten nicht geladen werden", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function populateTenantFilters() {
|
||||
// Typ + Kategorie aus META, Org aus Cache
|
||||
if (window.META && window.META.types) {
|
||||
populateSelect(document.getElementById("tenantFilterType"),
|
||||
window.META.types.filter(t => t.key !== "excluded"), "Alle Typen");
|
||||
}
|
||||
if (window.META && window.META.categories) {
|
||||
populateSelect(document.getElementById("tenantFilterCategory"),
|
||||
window.META.categories, "Alle Kategorien");
|
||||
}
|
||||
// Org-Liste aus den Daten extrahieren (eindeutig)
|
||||
const orgs = Array.from(new Set(tenantSourcesCache.map(s => s.org_name).filter(Boolean))).sort();
|
||||
populateSelect(
|
||||
document.getElementById("tenantFilterOrg"),
|
||||
orgs.map(o => ({ key: o, label: o })),
|
||||
"Alle Organisationen",
|
||||
);
|
||||
}
|
||||
|
||||
function applyTenantFilterAndSort() {
|
||||
const q = (tenantFilters.search || "").toLowerCase();
|
||||
let filtered = tenantSourcesCache.filter(s => {
|
||||
if (q && !(
|
||||
(s.name || "").toLowerCase().includes(q)
|
||||
|| (s.domain || "").toLowerCase().includes(q)
|
||||
|| (s.org_name || "").toLowerCase().includes(q)
|
||||
|| (s.url || "").toLowerCase().includes(q)
|
||||
)) return false;
|
||||
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;
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const va = String(a[tenantSort.field] ?? "").toLowerCase();
|
||||
const vb = String(b[tenantSort.field] ?? "").toLowerCase();
|
||||
const cmp = va.localeCompare(vb, "de");
|
||||
return tenantSort.asc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
renderTenantSources(filtered);
|
||||
// Sort-Icons aktualisieren
|
||||
document.querySelectorAll("#sub-tenant-sources th.sortable .sort-icon").forEach(el => el.textContent = "");
|
||||
const active = document.querySelector(`#sub-tenant-sources th.sortable[data-sort="${tenantSort.field}"] .sort-icon`);
|
||||
if (active) active.textContent = tenantSort.asc ? " \u25B2" : " \u25BC";
|
||||
}
|
||||
|
||||
function filterTenantSources() {
|
||||
tenantFilters.search = (document.getElementById("tenantSourceSearch")?.value || "").trim();
|
||||
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
|
||||
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
|
||||
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
|
||||
applyTenantFilterAndSort();
|
||||
}
|
||||
|
||||
function sortTenantSources(field) {
|
||||
if (tenantSort.field === field) tenantSort.asc = !tenantSort.asc;
|
||||
else { tenantSort.field = field; tenantSort.asc = true; }
|
||||
applyTenantFilterAndSort();
|
||||
}
|
||||
|
||||
function toggleTenantSelectAll(checked) {
|
||||
document.querySelectorAll("#tenantSourceTable input.tenant-select").forEach(cb => {
|
||||
cb.checked = checked;
|
||||
const id = parseInt(cb.dataset.id);
|
||||
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
|
||||
});
|
||||
updateBulkButton();
|
||||
}
|
||||
|
||||
function toggleTenantSelect(id, checked) {
|
||||
id = parseInt(id);
|
||||
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
|
||||
updateBulkButton();
|
||||
// Header-Checkbox anpassen
|
||||
const visible = document.querySelectorAll("#tenantSourceTable input.tenant-select").length;
|
||||
const checkedVisible = document.querySelectorAll("#tenantSourceTable input.tenant-select:checked").length;
|
||||
const all = document.getElementById("tenantSelectAll");
|
||||
if (all) all.checked = visible > 0 && visible === checkedVisible;
|
||||
}
|
||||
|
||||
function updateBulkButton() {
|
||||
const btn = document.getElementById("tenantBulkPromoteBtn");
|
||||
if (!btn) return;
|
||||
const n = tenantSelected.size;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = `Ausgewählte übernehmen (${n})`;
|
||||
}
|
||||
|
||||
async function bulkPromoteSelected() {
|
||||
if (tenantSelected.size === 0) return;
|
||||
const ids = Array.from(tenantSelected);
|
||||
const ok = await showConfirm(
|
||||
"Ausgewählte als Grundquelle übernehmen",
|
||||
`Sollen ${ids.length} Kundenquelle(n) als Grundquelle übernommen werden? Sie werden dann für alle Monitore verfügbar.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const result = await API.post("/api/sources/tenant/bulk-promote", { source_ids: ids });
|
||||
let msg = `${result.promoted} übernommen`;
|
||||
if (result.skipped && result.skipped.length) msg += `, ${result.skipped.length} übersprungen`;
|
||||
if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`;
|
||||
showToast(msg, result.failed && result.failed.length ? "warning" : "success");
|
||||
tenantSelected.clear();
|
||||
await loadTenantSources();
|
||||
} catch (err) {
|
||||
showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTenantSources(sources) {
|
||||
const tbody = document.getElementById("tenantSourceTable");
|
||||
const cols = 8;
|
||||
if (sources.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Kundenquellen</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
|
||||
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
|
||||
updateBulkButton();
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = sources.map((s) => `
|
||||
tbody.innerHTML = sources.map((s) => {
|
||||
const checked = tenantSelected.has(s.id) ? "checked" : "";
|
||||
return `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="tenant-select" data-id="${s.id}" ${checked} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>
|
||||
<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>${typeLabel(s.source_type)}</td>
|
||||
<td>${categoryLabel(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)}')">Übernehmen</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`;
|
||||
document.getElementById("tenantSourceCount").textContent = `${sources.length} / ${tenantSourcesCache.length} Kundenquellen`;
|
||||
updateBulkButton();
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
if (el) el.addEventListener("input", () => filterTenantSources());
|
||||
});
|
||||
|
||||
function promoteSource(id, name) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren