gwoe-antragspruefer/app/templates/index.html

1890 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ app_name }}</title>
<style>
:root {
--color-darkgray: #5a5a5a;
--color-green: #889e33;
--color-blue: #009da5;
--color-lightgray: #bfbfbf;
--color-bg: #f5f5f5;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Avenir', 'Segoe UI', sans-serif;
color: var(--color-darkgray);
line-height: 1.6;
background: var(--color-bg);
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-lightgray);
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.header h1 {
color: var(--color-blue);
font-size: 1.5rem;
}
.header .subtitle {
color: var(--color-darkgray);
font-size: 0.9rem;
}
.bundesland-select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-size: 0.9rem;
background: white;
cursor: pointer;
}
.bundesland-select:focus {
outline: none;
border-color: var(--color-blue);
}
/* Main Layout */
.main-container {
display: flex;
flex: 1;
min-height: 0;
}
/* Left Panel - List */
.list-panel {
width: 400px;
min-width: 300px;
background: white;
border-right: 1px solid var(--color-lightgray);
display: flex;
flex-direction: column;
}
.list-header {
padding: 1rem;
border-bottom: 1px solid var(--color-lightgray);
}
.search-row {
display: flex;
gap: 0.5rem;
}
.search-box {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-size: 0.9rem;
}
.btn-landtag {
padding: 0.5rem 0.75rem;
background: var(--color-green);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.btn-landtag:hover {
background: #728a2b;
}
.btn-landtag:disabled {
background: #ccc;
cursor: wait;
}
.list-filters {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-lightgray);
border-radius: 20px;
background: white;
cursor: pointer;
font-size: 0.8rem;
}
.filter-btn.active {
background: var(--color-blue);
color: white;
border-color: var(--color-blue);
}
.list-content {
flex: 1;
overflow-y: auto;
}
.list-item {
padding: 1rem;
border-bottom: 1px solid var(--color-lightgray);
cursor: pointer;
transition: background 0.2s;
}
.list-item:hover {
background: var(--color-bg);
}
.list-item.active {
background: #e8f4f5;
border-left: 3px solid var(--color-blue);
}
.list-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.25rem;
}
.list-item-id {
font-weight: bold;
color: var(--color-blue);
}
/* Bundesland-Badge: Im Listen-Item links neben der Drucksachen-Nummer.
Im Bundesland-spezifischen Modus per data-mode="single" am Container
ausgeblendet (redundant, da alle Einträge demselben Land zugehören). */
.bl-badge {
display: inline-block;
padding: 1px 6px;
margin-right: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-blue);
border: 1px solid var(--color-blue);
border-radius: 3px;
vertical-align: middle;
}
.list-content[data-mode="single"] .bl-badge { display: none; }
/* Detail-Header: Parlament-Name unter dem Titel, vor der Drucksache-Zeile */
.detail-parlament {
font-size: 0.85rem;
color: var(--color-blue);
font-weight: 600;
margin: 0.2rem 0 0.1rem;
}
.list-item-score {
font-size: 0.9rem;
font-weight: bold;
padding: 0.1rem 0.5rem;
border-radius: 4px;
}
/* Neue Skala -5 bis +5 */
.score-high { background: #155724; color: white; } /* +4 bis +5: dunkelgrün */
.score-mid { background: #889e33; color: white; } /* +2 bis +3: GWÖ-grün */
.score-neutral { background: #6c757d; color: white; } /* 0 bis +1: grau */
.score-low { background: #fd7e14; color: white; } /* -1 bis -2: orange */
.score-negative { background: #dc3545; color: white; } /* -3 bis -5: rot */
.score-none { background: #e2e3e5; color: #383d41; }
.status-unchecked {
background: #fff3cd;
color: #856404;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.status-checked {
background: #d4edda;
color: #155724;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.btn-check-now {
display: inline-block;
padding: 0.3rem 0.75rem;
background: var(--color-blue);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
margin-top: 0.5rem;
}
.btn-check-now:hover {
background: #007b82;
}
.btn-check-now:disabled {
background: #ccc;
cursor: wait;
}
.list-item-title {
font-size: 0.9rem;
color: var(--color-darkgray);
margin-bottom: 0.25rem;
}
.list-item-meta {
font-size: 0.8rem;
color: #888;
}
.list-item-tags {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
background: var(--color-bg);
border-radius: 3px;
}
/* Right Panel - Detail */
.detail-panel {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
.detail-placeholder {
text-align: center;
color: #888;
padding: 4rem;
}
.detail-card {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 1rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.detail-title {
font-size: 1.3rem;
color: var(--color-blue);
}
.detail-id {
font-size: 0.9rem;
color: #888;
}
.score-display {
text-align: center;
padding: 1rem;
}
.score-big {
font-size: 3rem;
font-weight: bold;
color: var(--color-blue);
}
.score-label {
font-size: 0.9rem;
color: #888;
}
.matrix-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
margin: 1rem 0;
}
.matrix-item {
padding: 0.75rem;
background: var(--color-bg);
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.matrix-label {
font-size: 0.85rem;
}
.matrix-rating {
font-weight: bold;
}
.rating-pos { color: var(--color-green); }
.rating-neg { color: #dc3545; }
.rating-neutral { color: #888; }
/* Matrix Table (5x5 Grid wie im PDF) */
.matrix-table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.85rem;
}
.matrix-table th, .matrix-table td {
border: 1px solid var(--color-lightgray);
padding: 0.4rem 0.5rem;
text-align: center;
}
.matrix-table thead th {
background: var(--color-blue);
color: white;
font-weight: normal;
}
.matrix-table tbody th {
background: var(--color-bg);
text-align: left;
font-weight: normal;
}
.matrix-table .positive {
background: var(--color-green);
color: white;
font-weight: bold;
}
.matrix-table .negative {
background: #dc3545;
color: white;
font-weight: bold;
}
.matrix-table .neutral {
background: #f0f0f0;
}
/* Themen Tags in Detail */
.themen-tags .tag {
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-blue);
color: white;
border-radius: 3px;
font-size: 0.8rem;
}
/* Kernpunkte Liste */
.kernpunkte-list {
margin-left: 1.5rem;
}
.kernpunkte-list li {
margin-bottom: 0.5rem;
}
.section-title {
font-size: 1.1rem;
color: var(--color-darkgray);
margin: 1.5rem 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-lightgray);
}
.text-block {
background: var(--color-bg);
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.strength-list, .weakness-list {
list-style: none;
}
.strength-list li::before {
content: "✓ ";
color: var(--color-green);
}
.weakness-list li::before {
content: "✗ ";
color: #dc3545;
}
.btn-pdf {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--color-blue);
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 1rem;
}
.btn-pdf:hover {
background: #007b82;
}
/* Upload Tab */
.upload-section {
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.tab-btn {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--color-darkgray);
}
.tab-btn.active {
border-bottom-color: var(--color-blue);
color: var(--color-blue);
}
textarea {
width: 100%;
min-height: 200px;
padding: 1rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.file-drop {
border: 2px dashed var(--color-lightgray);
border-radius: 8px;
padding: 3rem;
text-align: center;
cursor: pointer;
}
.file-drop:hover {
border-color: var(--color-blue);
}
.btn-analyze {
display: block;
width: 100%;
padding: 1rem;
background: var(--color-green);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.btn-analyze:hover {
background: #728a2b;
}
.btn-analyze:disabled {
background: #ccc;
cursor: not-allowed;
}
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--color-bg);
border-radius: 4px;
padding: 0.25rem;
margin-left: auto;
}
.mode-btn {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
border-radius: 4px;
color: var(--color-darkgray);
}
.mode-btn.active {
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Loading */
.loading {
display: flex;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid var(--color-lightgray);
border-top-color: var(--color-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Mobile Back-to-List Button (nur Mobile sichtbar) */
.btn-back-mobile {
display: none;
padding: 0.5rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-lightgray);
border-radius: 4px;
cursor: pointer;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-darkgray);
}
/* Responsive */
@media (max-width: 900px) {
.header {
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.header h1 {
font-size: 1.2rem;
}
.header .subtitle {
font-size: 0.8rem;
flex-basis: 100%;
order: 10;
}
.mode-toggle {
margin-left: auto;
flex-wrap: wrap;
}
.mode-btn {
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
}
.main-container {
flex-direction: column;
flex: 1;
min-height: 0;
}
.list-panel {
width: 100%;
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--color-lightgray);
}
.list-content {
max-height: 50vh;
}
.detail-panel {
padding: 1rem;
overflow: visible;
flex: none;
}
.btn-back-mobile {
display: inline-block;
}
}
@media (max-width: 600px) {
.header h1 { font-size: 1.1rem; }
.header .subtitle { display: none; }
.detail-card { padding: 1rem; }
.matrix-table { font-size: 0.7rem; }
.matrix-table th, .matrix-table td { padding: 0.25rem 0.3rem; }
.matrix-grid { grid-template-columns: 1fr; }
.upload-section { padding: 1rem; }
.file-drop { padding: 1.5rem; }
.score-big { font-size: 2.2rem; }
.detail-header { flex-wrap: wrap; gap: 0.5rem; }
}
/* Stats Bar */
.stats-bar {
display: flex;
gap: 2rem;
padding: 1rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-lightgray);
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--color-blue);
}
.stat-label {
font-size: 0.8rem;
color: #888;
}
</style>
</head>
<body>
<header class="header">
<h1>{{ app_name }}</h1>
<span class="subtitle">Gemeinwohl-Matrix 2.0 für Gemeinden</span>
<select class="bundesland-select" id="bundesland-select" onchange="changeBundesland(this.value)">
{% for bl in bundeslaender %}
<option value="{{ bl.code }}" {% if bl.active %}{% else %}disabled{% endif %}>
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
</option>
{% endfor %}
</select>
<div class="mode-toggle">
<button class="mode-btn active" onclick="showMode('browse')">📋 Durchsuchen</button>
<button class="mode-btn" onclick="showMode('tags')">🏷️ Tags</button>
<button class="mode-btn" onclick="showMode('upload')">📤 Prüfen</button>
<a href="/auswertungen" class="mode-btn" style="text-decoration: none;">📈 Auswertungen</a>
<a href="/quellen" class="mode-btn" style="text-decoration: none;">📚 Quellen</a>
<a href="/methodik" class="mode-btn" style="text-decoration: none;">🔍 Methodik</a>
<button id="auth-btn" class="mode-btn" style="border:none;cursor:pointer;">🔑 Anmelden</button>
</div>
</header>
<div class="main-container" id="browse-mode">
<!-- Left: List -->
<aside class="list-panel">
<div class="list-header">
<!--
#16: zwei klar getrennte Suchfelder. Das erste filtert
in der DB der bereits geprüften Anträge (Live, debounced).
Das zweite triggert per Enter oder Button eine Live-
Anfrage gegen den Landtag-Adapter. Beide schreiben in
dieselbe Liste, unterscheiden sich aber visuell und
semantisch klar.
-->
<div class="search-row" style="flex-direction: column; gap: 0.4rem;">
<div style="display: flex; gap: 0.4rem; width: 100%;">
<input type="text" class="search-box" id="search-input"
placeholder="📊 Suche in geprüften Anträgen (DB)…"
oninput="debounceSearch(this.value)"
style="flex: 1;">
</div>
<div style="display: flex; gap: 0.4rem; width: 100%;">
<input type="text" class="search-box" id="landtag-search-input"
placeholder="🏛️ Im Landtag suchen (live)…"
onkeydown="if(event.key==='Enter')searchLandtag()"
style="flex: 1;">
<button class="btn-landtag" id="btn-landtag" onclick="searchLandtag()">🔍 Suchen</button>
</div>
</div>
<div class="list-filters">
<button class="filter-btn active" data-filter="all" onclick="setScoreFilter('all', this)">Alle</button>
<button class="filter-btn" data-filter="high" onclick="setScoreFilter('high', this)">8-10</button>
<button class="filter-btn" data-filter="mid" onclick="setScoreFilter('mid', this)">5-7</button>
<button class="filter-btn" data-filter="low" onclick="setScoreFilter('low', this)">0-4</button>
<select id="partei-filter" onchange="setParteiFilter(this.value)" style="padding: 0.25rem 0.5rem; border-radius: 20px; border: 1px solid var(--color-lightgray); font-size: 0.8rem; cursor: pointer;">
<option value="">Alle Parteien</option>
</select>
</div>
</div>
<div class="stats-bar" style="padding: 0.5rem 1rem; gap: 1rem; flex-wrap: wrap; align-items: center;">
<span style="font-size: 0.8rem;"><strong id="stat-total">0</strong> geprüft · <strong id="stat-high">0</strong> vorbildlich · Ø <strong id="stat-avg">0</strong></span>
<span id="bundesland-stats" style="font-size: 0.8rem; color: var(--color-darkgray); display: none; gap: 0.6rem; flex-wrap: wrap;"></span>
<span style="color: var(--color-lightgray);">|</span>
<span id="partei-stats" style="font-size: 0.8rem; display: flex; gap: 0.75rem; flex-wrap: wrap;"></span>
</div>
<div class="list-content" id="list-content">
<div class="loading">
<div class="spinner"></div>
<span>Lade Bewertungen...</span>
</div>
</div>
</aside>
<!-- Right: Detail -->
<main class="detail-panel" id="detail-panel">
<div class="detail-placeholder">
<p>👈 Wähle einen Antrag aus der Liste</p>
</div>
</main>
</div>
<!-- Tags Mode -->
<div class="main-container" id="tags-mode" style="display: none;">
<aside class="list-panel" style="width: 100%; max-width: 400px;">
<div class="list-header">
<h3 style="margin-bottom: 0.5rem;">🏷️ Filter nach Tags</h3>
<p style="font-size: 0.85rem; color: #666; margin-bottom: 1rem;">Klicke auf Tags um zu filtern. Mehrfachauswahl zeigt Schnittmenge.</p>
<input type="text" class="search-box" id="tag-search-input" placeholder="Tags durchsuchen..." oninput="filterTagCloud(this.value)">
<div id="active-tags" style="margin-top: 0.75rem; display: flex; flex-wrap: wrap; gap: 0.5rem;"></div>
<button onclick="clearTagFilters()" style="margin-top: 0.75rem; padding: 0.5rem 1rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; display: none;" id="clear-tags-btn">✕ Filter zurücksetzen</button>
</div>
<div class="list-content" id="tag-cloud" style="padding: 1rem;">
<div class="loading">
<div class="spinner"></div>
<span>Lade Tags...</span>
</div>
</div>
</aside>
<main class="detail-panel" id="tag-results-panel">
<div class="detail-card">
<h3 style="margin-bottom: 1rem;">📋 Gefilterte Anträge</h3>
<div id="tag-results-list">
<p style="color: #888;">Wähle Tags aus der Wolke um Anträge zu filtern.</p>
</div>
</div>
</main>
</div>
<!-- Upload Mode -->
<div class="main-container" id="upload-mode" style="display: none;">
<div class="detail-panel">
<div class="upload-section">
<h2 style="margin-bottom: 1rem; color: var(--color-blue);">Neuen Antrag prüfen</h2>
<div class="tabs">
<button class="tab-btn active" onclick="showTab('text')">Text eingeben</button>
<button class="tab-btn" onclick="showTab('file')">PDF hochladen</button>
</div>
<div id="tab-text">
<textarea id="antrag-text" placeholder="Antragstext hier einfügen..."></textarea>
</div>
<div id="tab-file" style="display: none;">
<div class="file-drop" onclick="document.getElementById('file-input').click()">
<p>📄 PDF hier ablegen oder klicken</p>
<input type="file" id="file-input" accept=".pdf" style="display: none" onchange="handleFile(this)">
<p id="file-name" style="margin-top: 0.5rem; color: var(--color-blue);"></p>
</div>
</div>
<div style="margin-top: 1rem;">
<label>Bundesland:</label>
<select id="bundesland" style="padding: 0.5rem; margin-left: 0.5rem;">
<option value="" disabled selected>— Bundesland wählen —</option>
{% for bl in bundeslaender if bl.code != 'ALL' %}
<option value="{{ bl.code }}" {% if not bl.active %}disabled{% endif %}>
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
</option>
{% endfor %}
</select>
</div>
<button class="btn-analyze" id="analyze-btn" onclick="startAnalysis()">
🔍 GWÖ-Analyse starten
</button>
<div id="analysis-status" style="display: none;">
<div class="loading">
<div class="spinner"></div>
<span id="status-text">Analyse läuft...</span>
</div>
</div>
<div id="analysis-result" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</div>
<script>
let allAssessments = [];
let currentScoreFilter = 'all';
let currentParteiFilter = '';
let currentBundesland = 'ALL';
let searchTimeout = null;
let isSearching = false;
let selectedTags = new Set();
let allTags = {};
let currentUser = null; // #43: Auth-State
// #43: Auth prüfen beim Load. Steuert ob "Jetzt prüfen" aktiv ist.
async function initAuth() {
try {
const resp = await fetch('/api/auth/me');
const data = await resp.json();
currentUser = data.authenticated ? data : null;
} catch { currentUser = null; }
updateAuthUI();
}
function updateAuthUI() {
const authBtn = document.getElementById('auth-btn');
if (!authBtn) return;
if (currentUser) {
authBtn.textContent = '✓ Angemeldet (Logout)';
authBtn.classList.add('logged-in');
authBtn.style.color = '#889e33';
authBtn.onclick = () => {
// Cookie löschen + Keycloak-Logout
document.cookie = 'access_token=; Max-Age=0; path=/; secure; samesite=lax';
currentUser = null;
updateAuthUI();
loadAssessments(); // Liste neu rendern (Buttons deaktivieren)
};
// Bestehende Liste neu rendern damit Buttons aktiv werden
if (allAssessments.length > 0) renderList(allAssessments);
} else {
authBtn.textContent = '🔑 Anmelden';
authBtn.style.color = '';
authBtn.classList.remove('logged-in');
authBtn.onclick = async () => {
const resp = await fetch(`/api/auth/login-url?redirect=${encodeURIComponent(window.location.pathname)}`);
const data = await resp.json();
if (data.url) window.location.href = data.url;
};
}
}
// Partei-Normalisierung (global, für Stats + Labels)
function normalizePartei(f) {
const u = f.toUpperCase();
if (u === 'AFD') return 'AfD';
if (u === 'GRÜNE' || u === 'GRUENE' || u === 'BÜNDNIS 90/DIE GRÜNEN') return 'GRÜNE';
if (u === 'DIE LINKE') return 'LINKE';
return f;
}
// Map code → parlament_name, vom Backend mit dem Initial-Render geliefert.
// Wird im Detail-Header und im Listen-Item-Badge-Tooltip verwendet.
const PARLAMENT_NAMES = {{ parlament_names | tojson }};
// Load assessments on page load — localStorage-Auswahl wiederherstellen
document.addEventListener('DOMContentLoaded', () => {
initAuth(); // #43: Auth-State prüfen
const saved = localStorage.getItem('selectedBundesland');
const select = document.getElementById('bundesland-select');
if (saved) {
// Validieren: existiert die Option?
const exists = Array.from(select.options).some(
o => o.value === saved && !o.disabled
);
if (exists) {
currentBundesland = saved;
select.value = saved;
}
}
// Modus-Klasse für CSS (Badges aus/an)
document.getElementById('list-content').dataset.mode =
(currentBundesland === 'ALL') ? 'all' : 'single';
// Landtag-Button-State für Initial-Auswahl
const btnLandtag = document.getElementById('btn-landtag');
if (currentBundesland === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
}
loadAssessments();
});
function buildParteienFilter() {
// Alle Fraktionen sammeln
const parteien = new Set();
allAssessments.forEach(a => {
(a.fraktionen || []).forEach(f => parteien.add(f));
});
const select = document.getElementById('partei-filter');
select.innerHTML = '<option value="">Alle Parteien</option>';
Array.from(parteien).sort().forEach(p => {
select.innerHTML += `<option value="${p}">${p}</option>`;
});
}
function buildTagCloud() {
allTags = {};
allAssessments.forEach(a => {
(a.themen || []).forEach(tag => {
allTags[tag] = (allTags[tag] || 0) + 1;
});
});
renderTagCloud();
}
function renderTagCloud(filter = '') {
const container = document.getElementById('tag-cloud');
const filterLower = filter.toLowerCase();
// Sortiert nach Häufigkeit
const sorted = Object.entries(allTags)
.filter(([tag]) => tag.toLowerCase().includes(filterLower))
.sort((a, b) => b[1] - a[1]);
if (sorted.length === 0) {
container.innerHTML = '<p style="color: #888;">Keine Tags gefunden</p>';
return;
}
const maxCount = Math.max(...sorted.map(([, c]) => c));
container.innerHTML = '<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">' +
sorted.map(([tag, count]) => {
const size = 0.75 + (count / maxCount) * 0.75;
const isActive = selectedTags.has(tag);
return `<button
onclick="toggleTag('${tag.replace(/'/g, "\\'")}')"
style="
font-size: ${size}rem;
padding: 0.3rem 0.6rem;
border-radius: 20px;
border: 1px solid ${isActive ? 'var(--color-blue)' : 'var(--color-lightgray)'};
background: ${isActive ? 'var(--color-blue)' : 'white'};
color: ${isActive ? 'white' : 'var(--color-darkgray)'};
cursor: pointer;
"
title="${count} Anträge"
>${tag} <span style="font-size: 0.7rem; opacity: 0.7;">(${count})</span></button>`;
}).join('') +
'</div>';
}
function filterTagCloud(query) {
renderTagCloud(query);
}
function toggleTag(tag) {
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
} else {
selectedTags.add(tag);
}
updateTagFilterUI();
filterByTags();
}
function updateTagFilterUI() {
const container = document.getElementById('active-tags');
const clearBtn = document.getElementById('clear-tags-btn');
if (selectedTags.size === 0) {
container.innerHTML = '';
clearBtn.style.display = 'none';
} else {
container.innerHTML = Array.from(selectedTags).map(tag =>
`<span style="padding: 0.25rem 0.5rem; background: var(--color-blue); color: white; border-radius: 4px; font-size: 0.85rem;">
${tag} <span style="cursor: pointer; margin-left: 0.25rem;" onclick="toggleTag('${tag.replace(/'/g, "\\'")}')">&times;</span>
</span>`
).join('');
clearBtn.style.display = 'block';
}
renderTagCloud(document.getElementById('tag-search-input')?.value || '');
}
function clearTagFilters() {
selectedTags.clear();
updateTagFilterUI();
filterByTags();
}
function filterByTags() {
const resultsContainer = document.getElementById('tag-results-list');
if (selectedTags.size === 0) {
resultsContainer.innerHTML = '<p style="color: #888;">Wähle Tags aus der Wolke um Anträge zu filtern.</p>';
return;
}
// Schnittmenge: Anträge müssen ALLE ausgewählten Tags haben
const filtered = allAssessments.filter(a => {
const tags = new Set(a.themen || []);
return Array.from(selectedTags).every(t => tags.has(t));
});
if (filtered.length === 0) {
resultsContainer.innerHTML = '<p style="color: #888;">Keine Anträge mit dieser Tag-Kombination gefunden.</p>';
return;
}
resultsContainer.innerHTML = `
<p style="margin-bottom: 1rem; color: #666;">${filtered.length} Anträge gefunden</p>
${filtered.map(item => `
<div style="padding: 0.75rem; border-bottom: 1px solid var(--color-lightgray); cursor: pointer;" onclick="showMode('browse'); setTimeout(() => showDetail('${item.drucksache}'), 100);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: bold; color: var(--color-blue);">${item.drucksache}</span>
<span style="padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.85rem; ${item.gwoeScore >= 8 ? 'background: #155724; color: white;' : item.gwoeScore >= 5 ? 'background: #889e33; color: white;' : 'background: #dc3545; color: white;'}">${item.gwoeScore}/10</span>
</div>
<div style="font-size: 0.9rem; margin-top: 0.25rem;">${item.title || 'Ohne Titel'}</div>
<div style="font-size: 0.8rem; color: #888;">${(item.fraktionen || []).join(', ')}</div>
</div>
`).join('')}
`;
}
async function loadAssessments() {
try {
const url = `/api/assessments?bundesland=${encodeURIComponent(currentBundesland)}`;
const resp = await fetch(url);
allAssessments = await resp.json();
updateStats();
renderList(allAssessments);
buildParteienFilter();
buildTagCloud();
} catch (e) {
console.error('loadAssessments error:', e);
document.getElementById('list-content').innerHTML =
`<p style="padding: 1rem; color: #c00;">Fehler beim Laden: ${e.message}</p>`;
}
}
function updateStats() {
const checked = allAssessments.filter(a => a.status !== 'unchecked').length;
const high = allAssessments.filter(a => a.gwoeScore >= 8).length;
const avg = checked > 0 ? (allAssessments.filter(a => a.gwoeScore != null).reduce((s, a) => s + (a.gwoeScore || 0), 0) / checked).toFixed(1) : 0;
document.getElementById('stat-total').textContent = checked;
document.getElementById('stat-high').textContent = high;
document.getElementById('stat-avg').textContent = avg;
// Pro-Bundesland-Aufschlüsselung — nur im Bundesweit-Modus, und nur
// wenn tatsächlich mehr als ein Bundesland in der Liste vorkommt.
const blContainer = document.getElementById('bundesland-stats');
if (currentBundesland === 'ALL') {
const blStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null || !a.bundesland) return;
if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 };
blStats[a.bundesland].sum += a.gwoeScore;
blStats[a.bundesland].count += 1;
});
const codes = Object.keys(blStats);
if (codes.length > 1) {
const sortedBl = codes
.map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count }))
.sort((a, b) => b.avg - a.avg);
blContainer.innerHTML = sortedBl.map(b =>
`<span title="${PARLAMENT_NAMES[b.code] || b.code}">Ø <strong>${b.code}</strong> ${b.avg.toFixed(1)} <span style="color:#888">(n=${b.count})</span></span>`
).join(' · ');
blContainer.style.display = 'inline-flex';
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
// Partei-Durchschnitte berechnen (Normalisierung via globaler normalizePartei)
const parteiStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null) return;
(a.fraktionen || []).forEach(f => {
const norm = normalizePartei(f);
if (!parteiStats[norm]) parteiStats[norm] = { sum: 0, count: 0 };
parteiStats[norm].sum += a.gwoeScore;
parteiStats[norm].count += 1;
});
});
// Sortiert nach Durchschnitt (absteigend)
const sorted = Object.entries(parteiStats)
.map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count }))
.sort((a, b) => b.avg - a.avg);
const container = document.getElementById('partei-stats');
container.innerHTML = sorted.map(p => {
const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545';
return `<span style="color: ${color};"><strong>${p.partei}</strong> ${p.avg.toFixed(1)}</span>`;
}).join('');
}
function renderList(items) {
const container = document.getElementById('list-content');
if (items.length === 0) {
container.innerHTML = '<p style="padding: 1rem; color: #888;">Keine Ergebnisse</p>';
return;
}
container.innerHTML = items.map(item => {
const isUnchecked = item.status === 'unchecked';
// Skala 0-10
const scoreClass = isUnchecked ? 'status-unchecked' :
item.gwoeScore >= 8 ? 'score-high' :
item.gwoeScore >= 5 ? 'score-mid' :
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
const fraktionen = (item.fraktionen || []).join(', ') || 'k.A.';
const themen = (item.themen || []).slice(0, 3);
const scoreText = isUnchecked ? '⏳' : `${item.gwoeScore}/10`;
const blBadge = item.bundesland
? `<span class="bl-badge" title="${PARLAMENT_NAMES[item.bundesland] || item.bundesland}">${item.bundesland}</span>`
: '';
return `
<div class="list-item ${isUnchecked ? 'unchecked' : ''}" data-drucksache="${item.drucksache}" onclick="${isUnchecked ? '' : `showDetail('${item.drucksache}')`}">
<div class="list-item-header">
<span class="list-item-id">${blBadge}${item.drucksache}</span>
<span class="list-item-score ${scoreClass}">${scoreText}</span>
</div>
<div class="list-item-title">${(item.title || 'Ohne Titel').length > 80 ? (item.title.substring(0, 80) + '…') : (item.title || 'Ohne Titel')}</div>
<div class="list-item-meta">${fraktionen} · ${item.datum || ''}</div>
${isUnchecked ? `
<button class="btn-check-now"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="opacity:0.5;cursor:not-allowed;"'}
onclick="event.stopPropagation(); checkNow('${item.drucksache}', this)">
🔍 Jetzt prüfen
</button>
` : `
<div class="list-item-tags">
${themen.map(t => `<span class="tag">${t}</span>`).join('')}
</div>
`}
</div>
`;
}).join('');
}
function debounceSearch(query) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => performSearch(query), 300);
}
async function performSearch(query) {
if (query.length < 2) {
// Show all from DB with current filters
applyAllFilters();
return;
}
isSearching = true;
document.getElementById('list-content').innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>Suche...</span>
</div>
`;
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
let results = await resp.json();
// Score-Filter anwenden
if (currentScoreFilter !== 'all') {
results = applyScoreFilter(results, currentScoreFilter);
}
// Partei-Filter anwenden
if (currentParteiFilter) {
results = results.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(results);
} catch (e) {
document.getElementById('list-content').innerHTML =
'<p style="padding: 1rem; color: #888;">Suchfehler</p>';
}
isSearching = false;
}
async function checkNow(drucksache, btn) {
btn.disabled = true;
btn.textContent = '⏳ Prüfe...';
try {
const resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}&bundesland=${currentBundesland}`
});
const data = await resp.json();
if (data.status === 'queued') {
btn.textContent = '⏳ Analysiere...';
pollAnalysis(data.job_id, drucksache, btn);
} else if (data.status === 'already_checked') {
btn.textContent = '✓ Bereits geprüft';
setTimeout(() => loadAssessments(), 500);
} else {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
} catch (e) {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
}
async function reAnalyze(drucksache, bundesland, btn) {
if (!currentUser) {
alert('Bitte zuerst anmelden.');
return;
}
btn.disabled = true;
btn.style.background = '#ffc107';
btn.textContent = '⏳ Lösche alte Bewertung...';
try {
// Altes Assessment löschen
const delResp = await fetch(`/api/assessment/delete?drucksache=${encodeURIComponent(drucksache)}`, {method: 'DELETE'});
if (delResp.status === 401) {
btn.textContent = '🔒 Nicht angemeldet';
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
return;
}
btn.textContent = '⏳ Analyse gestartet...';
// Neue Analyse enqueuen
const resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}&bundesland=${encodeURIComponent(bundesland)}`
});
if (resp.status === 401) {
btn.textContent = '🔒 Nicht angemeldet';
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
return;
}
const data = await resp.json();
if (data.status === 'queued') {
btn.textContent = '⏳ Wird analysiert...';
btn.style.background = '#009da5';
pollAnalysis(data.job_id, drucksache, btn);
} else {
btn.textContent = '❌ ' + (data.detail || 'Fehler');
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
}
} catch (e) {
btn.textContent = '❌ ' + e.message;
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
}
}
async function pollAnalysis(jobId, drucksache, btn) {
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
if (data.status === 'completed') {
btn.textContent = '✓ Geprüft';
btn.style.background = '#889e33'; // Green
// Update this item in current list to show as checked
const listItem = btn.closest('.list-item');
if (listItem) {
listItem.classList.remove('unchecked');
listItem.onclick = () => showDetail(drucksache);
}
// Reload assessments in background (for internal list)
loadAssessments();
// Show detail for this item
setTimeout(() => showDetail(drucksache), 500);
} else if (data.status === 'failed') {
btn.textContent = '✗ Fehlgeschlagen';
btn.disabled = false;
} else {
setTimeout(() => pollAnalysis(jobId, drucksache, btn), 2000);
}
} catch (e) {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
}
function changeBundesland(code) {
currentBundesland = code;
localStorage.setItem('selectedBundesland', code);
// Filter zurücksetzen — Parteien & Tags pro Bundesland unterschiedlich,
// ein "LINKE"-Filter aus LSA würde in NRW eine leere Liste zeigen.
currentScoreFilter = 'all';
currentParteiFilter = '';
selectedTags.clear();
document.getElementById('search-input').value = '';
const landtagInput = document.getElementById('landtag-search-input');
if (landtagInput) landtagInput.value = '';
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.filter === 'all');
});
const parteiSelect = document.getElementById('partei-filter');
if (parteiSelect) parteiSelect.value = '';
// Upload-Mode-Dropdown synchronisieren. Bei "ALL" KEIN automatischer
// Default — der User soll im Upload-Form bewusst ein Bundesland wählen.
const uploadDropdown = document.getElementById('bundesland');
if (uploadDropdown) {
if (code === 'ALL') {
uploadDropdown.value = '';
} else {
uploadDropdown.value = code;
}
}
// Landtag-Suche-Button im Bundesweit-Modus deaktivieren
const btnLandtag = document.getElementById('btn-landtag');
if (code === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
} else {
btnLandtag.disabled = false;
btnLandtag.title = '';
}
// Modus-Klasse für CSS (Badges aus/an im Single-Modus)
document.getElementById('list-content').dataset.mode =
(code === 'ALL') ? 'all' : 'single';
loadAssessments();
}
async function searchLandtag() {
if (currentBundesland === 'ALL') {
alert('Landtag-Suche ist nur mit Auswahl eines konkreten Bundeslands möglich.\nBitte oben ein Bundesland auswählen.');
return;
}
// #16: Landtag-Suche zieht aus dem dedizierten Landtag-Input,
// nicht mehr aus dem DB-Suchfeld.
const query = document.getElementById('landtag-search-input').value.trim();
if (query.length < 2) {
alert('Bitte mindestens 2 Zeichen ins Landtag-Suchfeld eingeben');
return;
}
const btn = document.getElementById('btn-landtag');
btn.disabled = true;
btn.textContent = '⏳ Suche...';
document.getElementById('list-content').innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>Suche im Landtag ${currentBundesland}...</span>
</div>
`;
try {
const resp = await fetch(`/api/search-landtag?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
const results = await resp.json();
if (results.error) {
document.getElementById('list-content').innerHTML = `
<p style="padding: 1rem; color: #888;">${results.error}</p>
`;
} else if (results.length === 0) {
document.getElementById('list-content').innerHTML = `
<p style="padding: 1rem; color: #888;">Keine Treffer im Landtag für "${query}"</p>
`;
} else {
// Merge with checked status from DB
const checkedIds = new Set(allAssessments.map(a => a.drucksache));
const merged = results.map(r => ({
...r,
gwoeScore: checkedIds.has(r.drucksache)
? allAssessments.find(a => a.drucksache === r.drucksache)?.gwoeScore
: null,
status: checkedIds.has(r.drucksache) ? 'checked' : 'unchecked'
}));
renderList(merged);
}
} catch (e) {
document.getElementById('list-content').innerHTML = `
<p style="padding: 1rem; color: #888;">Suchfehler: ${e.message}</p>
`;
}
btn.disabled = false;
btn.textContent = '🔍 Im Landtag';
}
function filterList(query) {
const q = query.toLowerCase();
let filtered = allAssessments.filter(a =>
(a.title || '').toLowerCase().includes(q) ||
(a.drucksache || '').toLowerCase().includes(q) ||
(a.fraktionen || []).join(' ').toLowerCase().includes(q) ||
(a.themen || []).join(' ').toLowerCase().includes(q)
);
// Score-Filter anwenden
if (currentScoreFilter !== 'all') {
filtered = applyScoreFilter(filtered, currentScoreFilter);
}
// Partei-Filter anwenden
if (currentParteiFilter) {
filtered = filtered.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(filtered);
}
function setScoreFilter(filter, btn) {
currentScoreFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyAllFilters();
}
function setParteiFilter(partei) {
currentParteiFilter = partei;
applyAllFilters();
}
function applyAllFilters() {
let filtered = allAssessments;
// Score-Filter
if (currentScoreFilter !== 'all') {
filtered = applyScoreFilter(filtered, currentScoreFilter);
}
// Partei-Filter
if (currentParteiFilter) {
filtered = filtered.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(filtered);
}
function applyScoreFilter(items, filter) {
switch (filter) {
case 'high': return items.filter(a => a.gwoeScore >= 8);
case 'mid': return items.filter(a => a.gwoeScore >= 5 && a.gwoeScore < 8);
case 'low': return items.filter(a => a.gwoeScore < 5);
default: return items;
}
}
function showDetail(drucksache) {
const item = allAssessments.find(a => a.drucksache === drucksache);
if (!item) return;
// Highlight active item
document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active'));
// Find and highlight the list item by drucksache
const listItem = document.querySelector(`.list-item[data-drucksache="${drucksache}"]`);
if (listItem) listItem.classList.add('active');
// Skala 0-10
const scoreClass = item.gwoeScore >= 8 ? 'score-high' :
item.gwoeScore >= 5 ? 'score-mid' :
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
// Matrix als 5x5-Tabelle wie im PDF
const matrixData = {};
(item.gwoeMatrix || []).forEach(m => { matrixData[m.field] = m; });
const rowLabels = {
'A': 'Lieferant:innen',
'B': 'Finanzen',
'C': 'Führung/Verwaltung',
'D': 'Bürger:innen',
'E': 'Gesellschaft/Natur'
};
// Spaltenüberschriften der GWÖ-Matrix (5 Werte)
const colLabels = {
1: 'Menschen-würde',
2: 'Solidarität',
3: 'Ökol. Nachh.',
4: 'Soz. Gerecht.',
5: 'Transparenz'
};
const colFull = {
1: 'Menschenwürde',
2: 'Solidarität',
3: 'Ökologische Nachhaltigkeit',
4: 'Soziale Gerechtigkeit',
5: 'Transparenz & Mitbestimmung'
};
let matrixTableHtml = '<table class="matrix-table"><thead><tr><th></th>';
for (let col = 1; col <= 5; col++) matrixTableHtml += `<th title="${colFull[col]}">${colLabels[col]}</th>`;
matrixTableHtml += '</tr></thead><tbody>';
['A', 'B', 'C', 'D', 'E'].forEach(row => {
matrixTableHtml += `<tr><th>${row}: ${rowLabels[row]}</th>`;
for (let col = 1; col <= 5; col++) {
const field = `${row}${col}`;
const entry = matrixData[field];
if (entry) {
const cssClass = entry.rating > 0 ? 'positive' : (entry.rating < 0 ? 'negative' : 'neutral');
matrixTableHtml += `<td class="${cssClass}" title="${entry.aspect || entry.label}">${entry.symbol}</td>`;
} else {
matrixTableHtml += '<td></td>';
}
}
matrixTableHtml += '</tr>';
});
matrixTableHtml += '</tbody></table>';
// Zusätzlich die Detail-Liste der bewerteten Felder
const matrixDetailHtml = (item.gwoeMatrix || []).map(m => `
<div class="matrix-item">
<span class="matrix-label">${m.field}: ${m.label}</span>
<span class="matrix-rating ${m.rating > 0 ? 'rating-pos' : m.rating < 0 ? 'rating-neg' : 'rating-neutral'}">${m.symbol}</span>
</div>
`).join('');
const stärkenHtml = (item.stärken || []).map(s => `<li>${s}</li>`).join('');
const schwächenHtml = (item.schwächen || []).map(s => `<li>${s}</li>`).join('');
// Verbesserungsvorschläge formatieren
const verbesserungenHtml = (item.verbesserungen || []).map(v => {
// Redline-Format: **fett** = grün/neu, ~~durchgestrichen~~ = rot/gelöscht
let vorschlag = v.vorschlag || '';
vorschlag = vorschlag.replace(/\*\*([^*]+)\*\*/g, '<span style="color: #889e33; font-weight: bold;">$1</span>');
vorschlag = vorschlag.replace(/~~([^~]+)~~/g, '<span style="color: #d00000; text-decoration: line-through;">$1</span>');
return `
<div style="margin: 0.75rem 0; padding: 0.75rem; border: 1px solid var(--color-lightgray); border-radius: 4px;">
<div style="background: #f5f5f5; padding: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem;">
<strong>Original:</strong><br>${v.original || '-'}
</div>
<div style="background: rgba(136, 158, 51, 0.1); border-left: 3px solid #889e33; padding: 0.5rem; font-size: 0.9rem;">
<strong>Vorschlag:</strong><br>${vorschlag}
</div>
<div style="font-size: 0.85rem; color: #666; margin-top: 0.5rem; font-style: italic;">
${v.begruendung || ''}
</div>
</div>
`}).join('');
// Issue #47: Zitat-URLs zu Cite-Endpoint umschreiben für gelbes
// Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments
// (statische /static/referenzen/X.pdf#page=N) und nativ für
// Post-#47 (die schon /api/wahlprogramm-cite enthalten).
// makeCiteUrl baut die Highlight-URL und hängt ds+bl an,
// damit der Server bei nicht-auffindbaren Zitaten automatisch
// eine Re-Analyse triggern kann (#47 + #60).
function makeCiteUrl(z, ds, bl) {
if (!z || !z.url) return '#';
const extra = (ds && bl) ? `&ds=${encodeURIComponent(ds)}&bl=${encodeURIComponent(bl)}` : '';
// Schon eine Cite-URL? ds/bl anhängen + #page=N.
if (z.url.includes('/api/wahlprogramm-cite')) {
const m = z.url.match(/seite=(\d+)/);
const page = m ? m[1] : '';
const base = z.url.split('#')[0];
return base + extra + (page ? '#page=' + page : '');
}
// Statische URL umschreiben
const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/);
if (m && z.text) {
const pdf = m[1];
const page = m[2];
const q = encodeURIComponent((z.text || '').substring(0, 200));
return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}${extra}#page=${page}`;
}
return z.url;
}
const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => {
// Zitate formatieren mit klickbaren Links + Highlighting
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => {
const isVerified = z.verified !== false;
const borderColor = isVerified ? '#889e33' : '#ffc107';
const bgColor = isVerified ? '#f8f9fa' : '#fffbf0';
const badge = isVerified
? '<span style="font-size:0.7rem;color:#889e33;">✓ verifiziert</span>'
: '<span style="font-size:0.7rem;color:#b8860b;">~ paraphrasiert (nicht wörtlich im Programm)</span>';
return `
<div style="margin: 0.5rem 0; padding: 0.5rem; background: ${bgColor}; border-left: 3px ${isVerified ? 'solid' : 'dashed'} ${borderColor}; font-size: 0.85rem;">
<em>"${z.text}"</em><br>
${z.quelle ? `<a href="${makeCiteUrl(z, item.drucksache, item.bundesland)}" target="_blank" style="color: #009da5; font-size: 0.8rem;">
📄 ${z.quelle}
</a>` : ''}
${badge}
</div>`;
}).join('');
// Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate.
// Differenziert zwischen "Score 0 = keine Quellen" (LLM hat
// Force-Honesty befolgt) und "Score > 0 aber 0 Zitate" (LLM
// hat trotzdem geratet oder Zitate wurden von reconstruct_zitate
// verworfen). Nur bei Scores > 0 warnen, weil Score 0 schon
// selbsterklärend ist.
const wpScore = wp.wahlprogramm?.score ?? 0;
const wpZitateCount = (wp.wahlprogramm?.zitate || []).length;
const noQuotesWarning = (wpScore > 0 && wpZitateCount === 0) ? `
<div style="margin: 0.5rem 0; padding: 0.5rem; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 0.8rem; color: #856404;">
&#9888; Zu diesem Themenkomplex konnten keine konkreten Formulierungen im Wahlprogramm gefunden werden — Score basiert auf der allgemeinen Programmatik der Partei.
</div>
` : '';
// Labels: Antragsteller:in (aus item.fraktionen) und Landesregierung
// istAntragsteller/istRegierung aus dem LLM ist oft null — ableiten.
const isAntragsteller = (item.fraktionen || []).some(f => normalizePartei(f) === normalizePartei(wp.fraktion));
const roleLabels = [];
if (wp.istAntragsteller || isAntragsteller) roleLabels.push('<span style="background:#889e33;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;">Antragsteller:in</span>');
if (wp.istRegierung) roleLabels.push('<span style="background:#009da5;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;">Landesregierung</span>');
return `
<div style="margin: 0.75rem 0; padding: 0.75rem; background: var(--color-bg); border-radius: 4px;">
<strong>${wp.fraktion}</strong> ${roleLabels.join(' ')}<br>
<div style="margin: 0.5rem 0;">
Wahlprogramm: <strong>${wp.wahlprogramm?.score || '-'}/10</strong> ·
Parteiprogramm: <strong>${wp.parteiprogramm?.score || '-'}/10</strong>
</div>
${wp.wahlprogramm?.begründung ? `<div style="font-size: 0.9rem; color: #555; margin-bottom: 0.5rem;">${wp.wahlprogramm.begründung}</div>` : ''}
${noQuotesWarning}
${zitateHtml}
</div>
`}).join('');
document.getElementById('detail-panel').innerHTML = `
<div class="detail-card">
<button class="btn-back-mobile" onclick="document.querySelector('.list-panel').scrollIntoView({behavior:'smooth', block:'start'})">← Zur Liste</button>
<div class="detail-header">
<div>
<div class="detail-title">${item.title || 'Ohne Titel'}</div>
${item.bundesland && PARLAMENT_NAMES[item.bundesland] ? `<div class="detail-parlament">${PARLAMENT_NAMES[item.bundesland]}</div>` : ''}
<div class="detail-id">${item.drucksache} · ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}${item.updatedAt ? ` · Bewertet ${new Date(item.updatedAt).toLocaleDateString('de-DE')}` : ''}</div>
</div>
<div class="score-display">
<div class="score-big ${scoreClass}">${item.gwoeScore}</div>
<div class="score-label">GWÖ-Score</div>
</div>
</div>
${item.themen && item.themen.length > 0 ? `
<div class="themen-tags" style="margin-bottom: 1rem;">
${item.themen.map(t => `<span class="tag">${t}</span>`).join(' ')}
</div>
` : ''}
<h3 class="section-title">Zusammenfassung</h3>
<div class="text-block">${item.antragZusammenfassung || '-'}</div>
${item.antragKernpunkte && item.antragKernpunkte.length > 0 ? `
<h3 class="section-title">Kernpunkte</h3>
<ul class="kernpunkte-list">${item.antragKernpunkte.map(k => `<li>${k}</li>`).join('')}</ul>
` : ''}
<h3 class="section-title">GWÖ-Begründung</h3>
<div class="text-block">${item.gwoeBegründung || '-'}</div>
${item.gwoeSchwerpunkt ? `
<h3 class="section-title">GWÖ-Schwerpunkt</h3>
<div class="text-block">${item.gwoeSchwerpunkt}</div>
` : ''}
${item.gwoeMatrix && item.gwoeMatrix.length > 0 ? `
<h3 class="section-title">GWÖ-Matrix</h3>
${matrixTableHtml}
<div class="matrix-grid" style="margin-top: 0.75rem;">${matrixDetailHtml}</div>
` : ''}
${wahlprogrammHtml ? `
<h3 class="section-title">Programmtreue</h3>
${wahlprogrammHtml}
` : ''}
${stärkenHtml ? `
<h3 class="section-title">Stärken</h3>
<ul class="strength-list">${stärkenHtml}</ul>
` : ''}
${schwächenHtml ? `
<h3 class="section-title">Schwächen</h3>
<ul class="weakness-list">${schwächenHtml}</ul>
` : ''}
${verbesserungenHtml ? `
<h3 class="section-title">Verbesserungsvorschläge</h3>
${verbesserungenHtml}
` : ''}
${item.verbesserungspotenzial ? `
<h3 class="section-title">Verbesserungspotenzial</h3>
<div class="text-block">${item.verbesserungspotenzial}</div>
` : ''}
<h3 class="section-title">Empfehlung</h3>
<div class="text-block">
<strong>${item.empfehlungSymbol || ''} ${item.empfehlung || '-'}</strong>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;">
<a href="${item.link}" target="_blank" class="btn-pdf">📄 Original-PDF</a>
<a href="/api/assessment/pdf?drucksache=${encodeURIComponent(item.drucksache)}" class="btn-pdf" style="background: var(--color-green);">📥 GWÖ-Report</a>
<button class="btn-pdf" style="background: #6c757d; border: none; cursor: pointer;"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="background:#6c757d;opacity:0.5;cursor:not-allowed;"'}
onclick="reAnalyze('${item.drucksache}', '${item.bundesland}', this)">
🔄 Neu bewerten
</button>
</div>
<div style="margin-top: 0.75rem; font-size: 0.8rem; color: #999; border-top: 1px solid #eee; padding-top: 0.5rem;">
Bewertet am ${item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('de-DE') + ', ' + new Date(item.updatedAt).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}) + ' Uhr' : ''}
${item.source ? ` · Quelle: ${item.source}` : ''}
${item.model ? ` · Modell: ${item.model}` : ''}
</div>
</div>
`;
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
if (window.matchMedia('(max-width: 900px)').matches) {
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// Mode Toggle
function showMode(mode) {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
if (event && event.currentTarget) {
event.currentTarget.classList.add('active');
} else {
// Find and activate the right button
document.querySelectorAll('.mode-btn').forEach(b => {
if (b.textContent.includes(mode === 'browse' ? 'Durchsuchen' : mode === 'tags' ? 'Tags' : 'Prüfen')) {
b.classList.add('active');
}
});
}
document.getElementById('browse-mode').style.display = mode === 'browse' ? 'flex' : 'none';
document.getElementById('tags-mode').style.display = mode === 'tags' ? 'flex' : 'none';
document.getElementById('upload-mode').style.display = mode === 'upload' ? 'flex' : 'none';
}
// Upload Tab Toggle
function showTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tab-text').style.display = tab === 'text' ? 'block' : 'none';
document.getElementById('tab-file').style.display = tab === 'file' ? 'block' : 'none';
}
function handleFile(input) {
if (input.files[0]) {
document.getElementById('file-name').textContent = input.files[0].name;
}
}
async function startAnalysis() {
const btn = document.getElementById('analyze-btn');
const statusDiv = document.getElementById('analysis-status');
const resultDiv = document.getElementById('analysis-result');
const text = document.getElementById('antrag-text').value;
const file = document.getElementById('file-input').files[0];
const bundesland = document.getElementById('bundesland').value;
if (!text && !file) {
alert('Bitte Text eingeben oder PDF hochladen');
return;
}
if (!bundesland) {
alert('Bitte ein Bundesland wählen.');
return;
}
btn.disabled = true;
statusDiv.style.display = 'block';
resultDiv.style.display = 'none';
try {
const formData = new FormData();
if (text) formData.append('text', text);
if (file) formData.append('file', file);
formData.append('bundesland', bundesland);
formData.append('model', 'qwen-plus');
const resp = await fetch('/analyze', { method: 'POST', body: formData });
const data = await resp.json();
if (data.job_id) {
pollStatus(data.job_id);
} else {
throw new Error(data.detail || 'Fehler');
}
} catch (e) {
statusDiv.innerHTML = `<p style="color: #dc3545;">✗ Fehler: ${e.message}</p>`;
btn.disabled = false;
}
}
async function pollStatus(jobId) {
const statusText = document.getElementById('status-text');
const statusDiv = document.getElementById('analysis-status');
const resultDiv = document.getElementById('analysis-result');
const btn = document.getElementById('analyze-btn');
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
if (data.status === 'completed') {
statusDiv.style.display = 'none';
resultDiv.innerHTML = `
<p style="color: var(--color-green);">✓ Analyse abgeschlossen!</p>
<a href="/result/${jobId}" class="btn-pdf" style="background: var(--color-green);">📊 Ergebnis ansehen</a>
<a href="/result/${jobId}/pdf" class="btn-pdf">📄 PDF herunterladen</a>
`;
resultDiv.style.display = 'block';
btn.disabled = false;
} else if (data.status === 'failed') {
statusDiv.innerHTML = `<p style="color: #dc3545;">✗ Fehler: Analyse fehlgeschlagen. Bitte erneut versuchen.</p>`;
btn.disabled = false;
} else {
statusText.textContent = `Analysiere... (${data.status})`;
setTimeout(() => pollStatus(jobId), 2000);
}
} catch (e) {
statusDiv.innerHTML = `<p style="color: #dc3545;">✗ Fehler: ${e.message}</p>`;
btn.disabled = false;
}
}
</script>
</body>
</html>