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:
@@ -10,7 +10,7 @@ from config import STATIC_DIR, PORT
|
|||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
from auth import verify_password, create_token
|
from auth import verify_password, create_token
|
||||||
from models import LoginRequest, TokenResponse
|
from models import LoginRequest, TokenResponse
|
||||||
from routers import organizations, licenses, users, dashboard
|
from routers import organizations, licenses, users, dashboard, sources
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ app.include_router(organizations.router)
|
|||||||
app.include_router(licenses.router)
|
app.include_router(licenses.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(sources.router)
|
||||||
|
|
||||||
|
|
||||||
# --- Login ---
|
# --- Login ---
|
||||||
|
|||||||
167
src/routers/sources.py
Normale Datei
167
src/routers/sources.py
Normale Datei
@@ -0,0 +1,167 @@
|
|||||||
|
"""Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from auth import get_current_admin
|
||||||
|
from database import db_dependency
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSourceCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
url: Optional[str] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
|
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$")
|
||||||
|
category: str = Field(default="sonstige")
|
||||||
|
status: str = Field(default="active", pattern="^(active|inactive)$")
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSourceUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
url: Optional[str] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
|
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$")
|
||||||
|
category: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/global")
|
||||||
|
async def list_global_sources(
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Alle Grundquellen auflisten (tenant_id IS NULL)."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name"
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/global", status_code=201)
|
||||||
|
async def create_global_source(
|
||||||
|
data: GlobalSourceCreate,
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Neue Grundquelle anlegen."""
|
||||||
|
if data.url:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL",
|
||||||
|
(data.url,),
|
||||||
|
)
|
||||||
|
existing = await cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"URL bereits vorhanden: {existing['name']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
||||||
|
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
|
||||||
|
return dict(await cursor.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/global/{source_id}")
|
||||||
|
async def update_global_source(
|
||||||
|
source_id: int,
|
||||||
|
data: GlobalSourceUpdate,
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Grundquelle bearbeiten."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
updates[field] = value
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
values = list(updates.values()) + [source_id]
|
||||||
|
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
||||||
|
return dict(await cursor.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/global/{source_id}", status_code=204)
|
||||||
|
async def delete_global_source(
|
||||||
|
source_id: int,
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Grundquelle loeschen."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
|
||||||
|
)
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenant")
|
||||||
|
async def list_tenant_sources(
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT s.*, o.name as org_name
|
||||||
|
FROM sources s
|
||||||
|
LEFT JOIN organizations o ON o.id = s.tenant_id
|
||||||
|
WHERE s.tenant_id IS NOT NULL
|
||||||
|
ORDER BY o.name, s.category, s.name
|
||||||
|
""")
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenant/{source_id}/promote")
|
||||||
|
async def promote_to_global(
|
||||||
|
source_id: int,
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tenant-Quelle zur Grundquelle befoerdern."""
|
||||||
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
|
||||||
|
if row["tenant_id"] is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
|
||||||
|
|
||||||
|
if row["url"]:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL",
|
||||||
|
(row["url"],),
|
||||||
|
)
|
||||||
|
if await cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?",
|
||||||
|
(source_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
||||||
|
return dict(await cursor.fetchone())
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<button class="nav-tab active" data-section="dashboard">Dashboard</button>
|
<button class="nav-tab active" data-section="dashboard">Dashboard</button>
|
||||||
<button class="nav-tab" data-section="orgs">Organisationen</button>
|
<button class="nav-tab" data-section="orgs">Organisationen</button>
|
||||||
<button class="nav-tab" data-section="licenses">Lizenzen</button>
|
<button class="nav-tab" data-section="licenses">Lizenzen</button>
|
||||||
|
<button class="nav-tab" data-section="sources">Quellen</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Dashboard Section -->
|
<!-- Dashboard Section -->
|
||||||
@@ -193,6 +194,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Sources Section -->
|
||||||
|
<div class="section" id="sec-sources">
|
||||||
|
<div class="nav-tabs" id="sourceSubTabs">
|
||||||
|
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button>
|
||||||
|
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grundquellen -->
|
||||||
|
<div class="section active" id="sub-global-sources">
|
||||||
|
<div class="action-bar">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
|
||||||
|
<span class="text-secondary" id="globalSourceCount"></span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="newGlobalSourceBtn">+ Neue Grundquelle</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="globalSourceTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kundenquellen -->
|
||||||
|
<div class="section" id="sub-tenant-sources">
|
||||||
|
<div class="action-bar">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<input type="text" class="search-input" id="tenantSourceSearch" placeholder="Kundenquelle suchen...">
|
||||||
|
<span class="text-secondary" id="tenantSourceCount"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Organisation</th>
|
||||||
|
<th>Hinzugefuegt von</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tenantSourceTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal: New Organization -->
|
<!-- Modal: New Organization -->
|
||||||
<div class="modal-overlay" id="modalNewOrg">
|
<div class="modal-overlay" id="modalNewOrg">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -286,6 +353,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Modal: Source (Create/Edit) -->
|
||||||
|
<div class="modal-overlay" id="modalSource">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="sourceModalTitle">Neue Grundquelle</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal('modalSource')">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="sourceForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceName">Name</label>
|
||||||
|
<input type="text" id="sourceName" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceUrl">Feed-URL</label>
|
||||||
|
<input type="url" id="sourceUrl" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceDomain">Domain</label>
|
||||||
|
<input type="text" id="sourceDomain" placeholder="z.B. tagesschau.de">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceType">Typ</label>
|
||||||
|
<select id="sourceType">
|
||||||
|
<option value="rss_feed">RSS-Feed</option>
|
||||||
|
<option value="web_source">Webquelle</option>
|
||||||
|
<option value="excluded">Gesperrt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceCategory">Kategorie</label>
|
||||||
|
<select id="sourceCategory">
|
||||||
|
<option value="nachrichtenagentur">Nachrichtenagentur</option>
|
||||||
|
<option value="oeffentlich-rechtlich">Oeffentlich-Rechtlich</option>
|
||||||
|
<option value="qualitaetszeitung">Qualitaetszeitung</option>
|
||||||
|
<option value="behoerde">Behoerde</option>
|
||||||
|
<option value="fachmedien">Fachmedien</option>
|
||||||
|
<option value="think-tank">Think-Tank</option>
|
||||||
|
<option value="international">International</option>
|
||||||
|
<option value="regional">Regional</option>
|
||||||
|
<option value="boulevard">Boulevard</option>
|
||||||
|
<option value="sonstige" selected>Sonstige</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceStatus">Status</label>
|
||||||
|
<select id="sourceStatus">
|
||||||
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="inactive">Inaktiv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceNotes">Notizen</label>
|
||||||
|
<input type="text" id="sourceNotes" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
<div id="sourceError" class="error-msg" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('modalSource')">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Confirm -->
|
<!-- Modal: Confirm -->
|
||||||
<div class="modal-overlay" id="modalConfirm">
|
<div class="modal-overlay" id="modalConfirm">
|
||||||
<div class="modal" style="max-width: 400px;">
|
<div class="modal" style="max-width: 400px;">
|
||||||
@@ -304,5 +439,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script src="/static/js/sources.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
242
src/static/js/sources.js
Normale Datei
242
src/static/js/sources.js
Normale Datei
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren