Phase 4: SEPA-Validierung (schwifty), Globale Suche (Cmd+K) & Jahresbericht-Modul

- SEPA-Export: IBAN/BIC-Validierung via schwifty, Schuldner-Konto aus StiftungsKonto
- Globale Suche: Cmd+K Modal über Destinatäre, Pächter, Ländereien, Förderungen, Dokumente
- Jahresbericht: Vollständige Jahresbilanz mit Einnahmen/Ausgaben/Netto, Unterstützungen,
  Landabrechnungen, Verwaltungskosten nach Kategorie, PDF-Export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 12:57:36 +00:00
parent a79a0989d6
commit 2be72c3990
8 changed files with 695 additions and 160 deletions

View File

@@ -777,5 +777,223 @@
</script>
{% block javascript %}{% endblock %}
<!-- Phase 4: Globale Suche (Cmd+K) -->
<div id="global-search-overlay" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,0.5);" onclick="closeGlobalSearch()">
</div>
<div id="global-search-modal" style="display:none; position:fixed; top:15%; left:50%; transform:translateX(-50%); z-index:10000; width:min(600px, 90vw); background:#fff; border-radius:0.75rem; box-shadow:0 20px 60px rgba(0,0,0,0.3); overflow:hidden;">
<div style="padding:0.75rem 1rem; border-bottom:1px solid #e9ecef; display:flex; align-items:center; gap:0.5rem;">
<i class="fas fa-search" style="color:#6c757d;"></i>
<input id="global-search-input" type="text" placeholder="Suche über alle Bereiche..." autocomplete="off"
style="flex:1; border:none; outline:none; font-size:1rem; background:transparent; color:#212529;">
<kbd style="font-size:0.75rem; padding:2px 6px; background:#f8f9fa; border:1px solid #dee2e6; border-radius:4px; color:#6c757d;">Esc</kbd>
</div>
<div id="global-search-results" style="max-height:420px; overflow-y:auto; padding:0.5rem 0;">
<div class="px-3 py-4 text-center text-muted" id="global-search-hint">
<i class="fas fa-search fa-2x mb-2 d-block" style="opacity:0.3;"></i>
Mindestens 2 Zeichen eingeben …
</div>
</div>
</div>
<!-- Suche-Trigger in Topbar -->
<style>
#global-search-btn {
background: none;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 4px 10px;
color: #6c757d;
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
}
#global-search-btn:hover {
border-color: var(--racing-green);
color: var(--racing-green);
}
.search-result-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
cursor: pointer;
text-decoration: none;
color: #212529;
transition: background 0.1s;
}
.search-result-item:hover, .search-result-item.highlighted {
background: #f0f7f4;
color: var(--racing-green-dark);
}
.search-result-icon {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--racing-green);
color: white;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
font-size: 0.8rem;
}
.search-result-typ {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6c757d;
}
.search-group-label {
padding: 0.25rem 1rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #adb5bd;
font-weight: 600;
border-top: 1px solid #f0f0f0;
margin-top: 0.25rem;
}
.search-group-label:first-child { border-top: none; margin-top: 0; }
</style>
<script>
(function() {
const overlay = document.getElementById('global-search-overlay');
const modal = document.getElementById('global-search-modal');
const input = document.getElementById('global-search-input');
const resultsEl = document.getElementById('global-search-results');
const hintEl = document.getElementById('global-search-hint');
let debounceTimer = null;
let currentHighlight = -1;
let resultLinks = [];
function openGlobalSearch() {
overlay.style.display = 'block';
modal.style.display = 'block';
input.focus();
input.select();
}
window.closeGlobalSearch = function() {
overlay.style.display = 'none';
modal.style.display = 'none';
};
// Cmd+K / Ctrl+K öffnet Suche
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (modal.style.display === 'block') {
closeGlobalSearch();
} else {
openGlobalSearch();
}
}
if (e.key === 'Escape' && modal.style.display === 'block') {
closeGlobalSearch();
}
// Keyboard navigation
if (modal.style.display === 'block' && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
resultLinks = Array.from(resultsEl.querySelectorAll('.search-result-item'));
if (!resultLinks.length) return;
resultLinks.forEach(l => l.classList.remove('highlighted'));
if (e.key === 'ArrowDown') currentHighlight = Math.min(currentHighlight + 1, resultLinks.length - 1);
else currentHighlight = Math.max(currentHighlight - 1, 0);
if (currentHighlight >= 0) {
resultLinks[currentHighlight].classList.add('highlighted');
resultLinks[currentHighlight].scrollIntoView({ block: 'nearest' });
}
}
if (modal.style.display === 'block' && e.key === 'Enter') {
resultLinks = Array.from(resultsEl.querySelectorAll('.search-result-item'));
if (currentHighlight >= 0 && resultLinks[currentHighlight]) {
window.location.href = resultLinks[currentHighlight].href;
} else if (resultLinks.length === 1) {
window.location.href = resultLinks[0].href;
}
}
});
// Prevent modal click from closing
modal.addEventListener('click', function(e) { e.stopPropagation(); });
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
currentHighlight = -1;
const q = input.value.trim();
if (q.length < 2) {
hintEl.style.display = '';
resultsEl.innerHTML = '';
resultsEl.appendChild(hintEl);
return;
}
debounceTimer = setTimeout(function() { doSearch(q); }, 200);
});
function doSearch(q) {
fetch("{% url 'stiftung:globale_suche_api' %}?q=" + encodeURIComponent(q), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => renderResults(data.results, q))
.catch(() => {});
}
function renderResults(results, q) {
resultsEl.innerHTML = '';
if (!results || !results.length) {
const empty = document.createElement('div');
empty.className = 'px-3 py-4 text-center text-muted';
empty.innerHTML = '<i class="fas fa-search-minus fa-2x mb-2 d-block" style="opacity:0.3;"></i>Keine Ergebnisse für „' + escapeHtml(q) + '"';
resultsEl.appendChild(empty);
return;
}
// Group by type
const grouped = {};
results.forEach(r => {
if (!grouped[r.typ]) grouped[r.typ] = [];
grouped[r.typ].push(r);
});
Object.entries(grouped).forEach(([typ, items]) => {
const label = document.createElement('div');
label.className = 'search-group-label';
label.textContent = typ;
resultsEl.appendChild(label);
items.forEach(item => {
const a = document.createElement('a');
a.className = 'search-result-item';
a.href = item.url;
a.innerHTML = `
<div class="search-result-icon"><i class="${item.icon}"></i></div>
<div style="min-width:0;">
<div style="font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(item.titel)}</div>
${item.untertitel ? '<div class="search-result-typ">' + escapeHtml(item.untertitel) + '</div>' : ''}
</div>`;
a.addEventListener('click', closeGlobalSearch);
resultsEl.appendChild(a);
});
});
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Inject search button into topbar
document.addEventListener('DOMContentLoaded', function() {
const topbarActions = document.querySelector('.topbar-actions');
if (topbarActions) {
const btn = document.createElement('button');
btn.id = 'global-search-btn';
btn.onclick = openGlobalSearch;
btn.innerHTML = '<i class="fas fa-search"></i> Suche <kbd style="font-size:0.7rem; padding:1px 4px; background:#f8f9fa; border:1px solid #dee2e6; border-radius:3px; margin-left:4px;">⌘K</kbd>';
topbarActions.insertBefore(btn, topbarActions.firstChild);
}
});
})();
</script>
</body>
</html>