banyaro/backend/static/js/pages/events.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

717 lines
30 KiB
JavaScript
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.

/* ============================================================
BAN YARO — Events (Hundeveranstaltungen)
Liste/Karte · Filter · Erstellen/Bearbeiten
============================================================ */
window.Page_events = (() => {
// ----------------------------------------------------------
// Konstanten
// ----------------------------------------------------------
const TYPEN = [
{ id: 'alle', label: 'Alle', icon: 'ticket' },
{ id: 'ausstellung', label: 'Ausstellung', icon: 'trophy' },
{ id: 'training', label: 'Training', icon: 'graduation-cap'},
{ id: 'treffen', label: 'Treffen', icon: 'dog' },
{ id: 'markt', label: 'Markt', icon: 'shopping-bag' },
{ id: 'wettkampf', label: 'Wettkampf', icon: 'medal' },
{ id: 'sonstiges', label: 'Sonstiges', icon: 'push-pin' },
];
const TYP_COLOR = {
ausstellung: '#8b5cf6',
training: '#3b82f6',
treffen: '#10b981',
markt: '#f59e0b',
wettkampf: '#ef4444',
sonstiges: '#6b7280',
};
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
let _container = null;
let _state = null;
let _events = [];
let _filter = 'alle';
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
let _search = '';
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
let _clusterGroup = null;
let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
// ----------------------------------------------------------
// Phosphor-Icon-Helper
// ----------------------------------------------------------
function _icon(name) {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// _emptyState ersetzt durch UI.emptyState()
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_state = appState;
_render();
await _load();
}
async function refresh() {
await _load();
}
// ----------------------------------------------------------
// Render Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<div class="flex-1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div>
<div class="diary-search-wrap" style="margin:var(--space-2) var(--space-3) 0" id="ev-search-wrap">
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" class="diary-search-input" id="ev-search"
placeholder="Events durchsuchen…" autocomplete="off">
</div>
<div class="events-filter-bar by-tabs" id="ev-filter-bar">
${TYPEN.map(t => `
<button class="by-tab ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<div class="events-source-bar by-tabs" id="ev-source-bar">
<button class="by-tab active" data-ev-quelle="alle">Alle Quellen</button>
<button class="by-tab" data-ev-quelle="vdh">
<span class="ev-vdh-badge">VDH</span> VDH-Events
</button>
<button class="by-tab" data-ev-quelle="nutzer">Von Nutzern</button>
</div>
<div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" class="hidden"></div>
`;
_container.addEventListener('click', _onClick);
// Suche mit Debounce
let _searchTimer = null;
document.getElementById('ev-search')?.addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_search = e.target.value.trim().toLowerCase();
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
}, 300);
});
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _load() {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
listEl.innerHTML = UI.skeleton(3);
try {
_events = await API.events.list();
_renderList();
} catch (e) {
UI.toast(e.message, 'error');
}
}
// ----------------------------------------------------------
// Gefilterte Events ermitteln
// ----------------------------------------------------------
function _filtered() {
let evs = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
if (_quellFilter !== 'alle') {
evs = evs.filter(e => (e.quelle || 'nutzer') === _quellFilter);
}
if (_search) {
const q = _search;
evs = evs.filter(e =>
(e.titel || '').toLowerCase().includes(q) ||
(e.ort_name || '').toLowerCase().includes(q) ||
(e.beschreibung|| '').toLowerCase().includes(q)
);
}
return evs;
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = UI.emptyState({
icon: UI.icon('calendar-blank'),
title: _search ? 'Keine Events gefunden' : 'Keine Events in der Nähe',
text: _search
? `Keine Events passen zu „${UI.escape(_search)}".`
: 'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.',
});
return;
}
// Monats-Gruppierung
const groups = {};
for (const ev of filtered) {
const d = new Date(ev.datum + 'T00:00:00');
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (!groups[key]) groups[key] = { label, items: [] };
groups[key].items.push(ev);
}
let html = '';
for (const key of Object.keys(groups).sort()) {
const g = groups[key];
html += `<div class="events-month-label">${g.label}</div>`;
for (const ev of g.items) {
html += _cardHTML(ev);
}
}
listEl.innerHTML = html;
}
function _cardHTML(ev) {
const d = new Date(ev.datum + 'T00:00:00');
const dow = d.toLocaleDateString('de-DE', { weekday: 'short' });
const day = d.getDate();
const mon = d.toLocaleDateString('de-DE', { month: 'short' });
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
return `
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
<div class="events-date-badge">
<span class="day">${dow}</span>
<span class="num">${day}</span>
<span class="month">${mon}</span>
</div>
<div class="events-card-body">
<div class="events-card-title">
${UI.escape(ev.titel)}
${isVdh ? `<span class="ev-vdh-badge" title="Vom VDH importiert">VDH</span>` : ''}
</div>
<div class="events-card-meta">
<span class="events-badge" style="background:${color}20;color:${color}">${UI.icon(typ.icon)} ${typ.label}</span>
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escape(ev.ort_name)}` : ''}
</div>
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
${ev.link ? `<div class="events-card-actions">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escape(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
${_icon('arrow-square-out')} Details
</a>
</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" class="text-muted" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''}
</div>
</div>
`;
}
// ----------------------------------------------------------
// Karte
// ----------------------------------------------------------
async function _renderMap(filtered) {
const mapEl = document.getElementById('ev-map');
if (!mapEl) return;
await UI.loadLeaflet(true); // true = mit MarkerCluster
if (!_map) {
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
}
// Cluster-Gruppe aufräumen und neu befüllen
if (_clusterGroup) {
_map.removeLayer(_clusterGroup);
}
_clusterGroup = L.markerClusterGroup();
_markers = [];
const bounds = [];
for (const ev of filtered) {
if (!ev.lat || !ev.lon) continue;
const color = TYP_COLOR[ev.typ] || '#6b7280';
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg);color:#fff"><svg style="width:14px;height:14px;transform:rotate(45deg);fill:currentColor" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#${typ.icon}"></use></svg></div>`,
iconSize: [32, 32], iconAnchor: [16, 32],
});
const popup = `
<div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br>
<span style="color:var(--c-text-muted);font-size:12px">${datum}</span><br>
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div>
`;
const m = L.marker([ev.lat, ev.lon], { icon }).bindPopup(popup);
_clusterGroup.addLayer(m);
_markers.push(m);
bounds.push([ev.lat, ev.lon]);
}
_map.addLayer(_clusterGroup);
if (bounds.length) {
_map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
} else {
// Versuche Nutzerstandort, sonst Deutschland-Übersicht
try {
const pos = await API.getLocation({ timeout: 5000 });
_map.setView([pos.lat, pos.lon], 10);
} catch {
_map.setView([51.1657, 10.4515], 6);
}
}
_map.invalidateSize();
setTimeout(() => _map.invalidateSize(), 100);
}
// _loadLeaflet und _loadMarkerCluster ersetzt durch UI.loadLeaflet(true)
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _showDetail(id) {
let ev;
try { ev = await API.events.get(id); } catch { return; }
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
const myRsvp = _myRsvp[id] ?? null;
// RSVP-Bar (nur für eingeloggte User)
const rsvpBar = _state.user ? `
<div class="event-rsvp-bar" id="ev-rsvp-bar-${id}">
<button class="btn event-rsvp-btn ${myRsvp === 'going' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="going">
${_icon('check-circle')} Ich komme
</button>
<button class="btn event-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="maybe">
${_icon('question')} Vielleicht
</button>
${ev.rsvp_count ? `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto;display:none">${_icon('users')} 0 nehmen teil</span>`}
</div>
` : (ev.rsvp_count ? `<div class="event-rsvp-bar"><span class="event-attendees" data-ev-attendees="${id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span></div>` : '');
const body = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${UI.icon(typ.icon)} ${typ.label}</span>
${isVdh ? `<span class="ev-vdh-badge">VDH</span>` : ''}
</div>
<div class="events-detail-row">${_icon('calendar-dots')} ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">${_icon('map-pin')} ${UI.escape(ev.ort_name)}</div>` : ''}
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escape(ev.beschreibung)}</div>` : ''}
${ev.link ? `<div class="events-detail-row">
${_icon('arrow-square-out')}
<a href="${UI.escape(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a>
</div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
${_icon('user')} Veranstalter: ${UI.escape(ev.veranstalter_name || '')}
</div>
${rsvpBar}
<div id="ev-attendees-panel-${id}"></div>
`;
const footer = isOwn ? `
<button class="btn btn-secondary" id="ev-detail-edit">${_icon('pencil-simple')} Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">${_icon('trash')} Löschen</button>
` : (ev.link ? `
<a class="btn btn-primary" href="${UI.escape(ev.link)}" target="_blank" rel="noopener">
${_icon('arrow-square-out')} Zur Veranstaltung
</a>
` : '');
UI.modal.open({ title: UI.escape(ev.titel), body, footer });
document.getElementById('ev-detail-edit')?.addEventListener('click', () => {
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
});
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
// RSVP-Buttons
document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => {
btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus));
});
}
async function _handleRsvp(eventId, status) {
const current = _myRsvp[eventId] ?? null;
try {
if (current === status) {
// Toggle off → absagen
await API.events.cancelRsvp(eventId);
_myRsvp[eventId] = null;
} else {
const res = await API.events.rsvp(eventId, status);
_myRsvp[eventId] = status;
// Teilnehmerzähler aktualisieren
_updateAttendeeCount(eventId, res.rsvp_count);
}
// Button-Styles aktualisieren
document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? ''));
});
// Bei Absage Zähler neu laden
if (current === status) {
const attendees = await API.events.listRsvp(eventId);
const goingCount = attendees.filter(a => a.status === 'going').length;
_updateAttendeeCount(eventId, goingCount);
}
} catch (e) { UI.toast(e.message, 'error'); }
}
function _updateAttendeeCount(eventId, count) {
// Im Modal
const span = document.getElementById(`ev-attendees-${eventId}`);
if (span) {
if (count > 0) {
span.innerHTML = `${_icon('users')} ${count} nehmen teil`;
span.style.display = '';
} else {
span.style.display = 'none';
}
}
// In der Listenansicht (Event-Objekt aktualisieren)
const ev = _events.find(x => x.id === eventId);
if (ev) {
ev.rsvp_count = count;
// Karte neu rendern falls sichtbar
const card = document.querySelector(`[data-ev-id="${eventId}"]`);
if (card) card.outerHTML = _cardHTML(ev);
}
}
async function _showAttendees(eventId) {
const panel = document.getElementById(`ev-attendees-panel-${eventId}`);
if (!panel) return;
if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; }
try {
const attendees = await API.events.listRsvp(eventId);
if (!attendees.length) { panel.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">Noch keine Zusagen.</p>'; }
else {
panel.innerHTML = `
<div class="ev-attendees-list">
${attendees.map(a => `
<span class="ev-attendee-chip">
${a.status === 'going' ? _icon('check-circle') : _icon('question')}
${UI.escape(a.name)}
</span>
`).join('')}
</div>`;
}
panel.dataset.loaded = '1';
} catch { /* ignore */ }
}
async function _deleteEvent(ev) {
if (!confirm(`"${ev.titel}" wirklich löschen?`)) return;
try {
await API.events.delete(ev.id);
UI.modal.close();
UI.toast('Event gelöscht.');
await _load();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ----------------------------------------------------------
// Erstellen / Bearbeiten
// ----------------------------------------------------------
function openNew() { _openForm(null); }
function _openForm(ev) {
const isEdit = !!ev;
const id = 'ev-form';
const body = `
<form id="${id}">
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" name="titel" required value="${ev ? UI.escape(ev.titel) : ''}">
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${ev?.datum || ''}">
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input class="form-control" type="time" name="uhrzeit" value="${ev?.uhrzeit || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Typ *</label>
<select class="form-control" name="typ">
${TYPEN.filter(t => t.id !== 'alle').map(t =>
`<option value="${t.id}" ${ev?.typ === t.id ? 'selected' : ''}>${UI.icon(t.icon)} ${t.label}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Ort / Veranstaltungsort</label>
<input class="form-control" name="ort_name" placeholder="z.B. Stadtpark München" value="${ev?.ort_name || ''}">
</div>
<div class="form-group">
<label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div>
</div>
<div class="form-group mt-3">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div>
<div class="form-group">
<label class="form-label">Link / Website</label>
<input class="form-control" type="url" name="link" placeholder="https://..." value="${ev?.link || ''}">
</div>
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" class="w-full">
${isEdit ? 'Speichern' : 'Event erstellen'}
</button>
<div class="flex-gap-2">
${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
</div>
`;
UI.modal.open({ title: isEdit ? 'Event bearbeiten' : 'Neues Event', body, footer });
document.getElementById('ev-form-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Event löschen?', message: 'Nicht rückgängig.', confirmText: 'Löschen', danger: true,
});
if (ok) await _deleteEvent(ev);
});
// Location-Picker initialisieren
const _picker = UI.locationPicker({
containerId: 'ev-location-picker',
});
if (ev?.lat && ev?.lon) {
_picker.setValue(ev.lat, ev.lon, ev.ort_name || null);
}
const form = document.getElementById(id);
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
form.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(form);
const loc = _picker.getValue();
const data = {
titel: fd.get('titel'),
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit') || null,
typ: fd.get('typ'),
ort_name: loc.name || fd.get('ort_name') || null,
lat: loc.lat || null,
lon: loc.lon || null,
beschreibung: fd.get('beschreibung') || null,
link: fd.get('link') || null,
};
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {
isEdit ? await API.events.update(ev.id, data) : await API.events.create(data);
UI.modal.close();
UI.toast(isEdit ? 'Event aktualisiert.' : 'Event erstellt!');
await _load();
} catch (err) {
UI.toast(err.message, 'error');
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Speichern' : 'Event erstellen'; }
}
});
}
// ----------------------------------------------------------
// Click-Handler
// ----------------------------------------------------------
function _onClick(e) {
// Quelle-Filter
const sourceBtn = e.target.closest('[data-ev-quelle]');
if (sourceBtn) {
_quellFilter = sourceBtn.dataset.evQuelle;
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
return;
}
// Typ-Filter
const filterBtn = e.target.closest('[data-ev-typ]');
if (filterBtn) {
_filter = filterBtn.dataset.evTyp;
document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter));
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
return;
}
// View-Toggle
const viewBtn = e.target.closest('[data-ev-view]');
if (viewBtn) {
_view = viewBtn.dataset.evView;
document.querySelectorAll('[data-ev-view]').forEach(b => b.classList.toggle('active', b.dataset.evView === _view));
const listEl = document.getElementById('ev-list');
const mapEl = document.getElementById('ev-map');
if (_view === 'karte') {
listEl.style.display = 'none';
mapEl.style.display = 'block';
// Erst div sichtbar machen, dann Karte initialisieren
_renderMap(_filtered());
} else {
// Karte sauber entfernen
if (_map) {
_map.remove();
_map = null;
_clusterGroup = null;
_markers = [];
}
mapEl.style.display = 'none';
listEl.style.display = '';
_renderList();
}
return;
}
// Neu-Button
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
// Externer Link — nicht als Karten-Klick behandeln
if (e.target.closest('.ev-ext-link')) return;
// Bearbeiten-Icon auf Karte
const editBtn = e.target.closest('[data-ev-edit]');
if (editBtn) {
e.stopPropagation();
const id = parseInt(editBtn.dataset.evEdit);
const ev = _events.find(x => x.id === id);
if (ev) _openForm(ev);
return;
}
// Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal)
const attendeesBtn = e.target.closest('[data-ev-attendees]');
if (attendeesBtn) {
e.stopPropagation();
_showAttendees(parseInt(attendeesBtn.dataset.evAttendees));
return;
}
// Notiz-Button
const noteBtn = e.target.closest('.ev-note-btn');
if (noteBtn) {
e.stopPropagation();
_openNoteModal(
'event',
parseInt(noteBtn.dataset.evNoteId),
noteBtn.dataset.evNoteLabel,
noteBtn.dataset.evNoteOrt || null
);
return;
}
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="ev-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Event…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="ev-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="ev-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#ev-note-close')?.addEventListener('click', close);
ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#ev-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, openNew, _openDetail: _showDetail };
})();