banyaro/backend/static/js/pages/poison.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

738 lines
29 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 — Giftköder-Alarm (Sprint 2)
Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular.
============================================================ */
window.Page_poison = (() => {
const _CACHE_KEY = 'by_poison_cache';
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _map = null;
let _markers = [];
let _userMarker = null;
let _reports = [];
let _userPos = null;
const TYPEN = {
unbekannt: { label: 'Unbekannt', icon: 'question', color: '#e67e22' },
koeoder: { label: 'Köder', icon: 'fish', color: '#e74c3c' },
vergiftet: { label: 'Vergiftetes Tier', icon: 'skull', color: '#8e44ad' },
chemikalie: { label: 'Chemikalie', icon: 'flask', color: '#c0392b' },
andere: { label: 'Andere Gefahr', icon: 'warning', color: '#d35400' },
};
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH — Navigation zur bereits geladenen Seite
// ----------------------------------------------------------
async function refresh() {
if (_userPos) await _loadReports();
}
// ----------------------------------------------------------
// OPEN NEW — vom + Button
// ----------------------------------------------------------
function openNew() {
_showReportForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur aufbauen
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
<button class="btn btn-secondary" id="poison-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
<button class="btn btn-danger" id="poison-btn-report">${UI.icon('warning-octagon')} Giftköder melden</button>
</div>
<div id="poison-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
</div>
<div style="font-size:10px;color:var(--c-text-secondary);
text-align:right;margin-bottom:var(--space-4);
padding:2px var(--space-2) 0">
© OpenStreetMap-Mitwirkende
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('phone')} <strong>110</strong> Polizei
</a>
<button class="btn btn-secondary" id="poison-btn-erstehilfe" class="flex-1">
${UI.icon('first-aid')} Erste Hilfe & Tiergift
</button>
</div>
<p id="poison-info"
style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-3)">
Standort wird ermittelt…
</p>
<div id="poison-list"></div>
`;
document.getElementById('poison-btn-locate')
?.addEventListener('click', _locateUser);
document.getElementById('poison-btn-report')
?.addEventListener('click', _showReportForm);
document.getElementById('poison-btn-erstehilfe')
?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' }));
await UI.loadLeaflet();
_initMap();
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
const mapEl = document.getElementById('poison-map');
if (!mapEl || !window.L || _map) return;
_map = L.map('poison-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6); // Deutschland-Mitte
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
}
// ----------------------------------------------------------
// STANDORT ERMITTELN + LADEN
// ----------------------------------------------------------
async function _locateAndLoad() {
try {
_userPos = await API.getLocation({ timeout: 8000 });
_showUserOnMap();
} catch {
_userPos = null;
}
await _loadReports();
}
async function _locateUser() {
const btn = document.getElementById('poison-btn-locate');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation({ timeout: 8000 });
_showUserOnMap();
if (_map) _map.setView([_userPos.lat, _userPos.lon], 13);
await _loadReports();
} catch {
UI.toast.warning('Standort konnte nicht ermittelt werden.');
}
UI.setLoading(btn, false);
}
function _showUserOnMap() {
if (!_map || !window.L || !_userPos) return;
if (_userMarker) _map.removeLayer(_userMarker);
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
radius : 9,
fillColor : '#3498db',
color : '#fff',
weight : 2,
fillOpacity : 0.9,
}).addTo(_map).bindPopup('<b>Du bist hier</b>');
_map.setView([_userPos.lat, _userPos.lon], 13);
}
// ----------------------------------------------------------
// MELDUNGEN LADEN
// ----------------------------------------------------------
async function _loadReports() {
const infoEl = document.getElementById('poison-info');
if (!_userPos) {
_reports = [];
_renderList();
if (infoEl) infoEl.textContent =
'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).';
return;
}
try {
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {}
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
if (infoEl) {
infoEl.textContent = _reports.length > 0
? `${_reports.length} aktive Meldung${_reports.length !== 1 ? 'en' : ''} im Umkreis von 10 km`
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
}
} catch {
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
_reports = JSON.parse(raw).data || [];
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
if (infoEl) infoEl.textContent = `${_reports.length} gecachte Meldung${_reports.length !== 1 ? 'en' : ''} (Offline)`;
UI.toast.info('Offline — zeige zuletzt geladene Daten.');
return;
}
} catch {}
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
// ----------------------------------------------------------
// KARTEN-MARKER
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map || !window.L) return;
_markers.forEach(m => _map.removeLayer(m));
_markers = [];
_reports.forEach(r => {
const typ = TYPEN[r.typ] || TYPEN.unbekannt;
const distStr = r.distanz_m < 1000
? `${r.distanz_m} m`
: `${(r.distanz_m / 1000).toFixed(1)} km`;
const marker = UI.leafletMarker({ lat: r.lat, lon: r.lon, color: typ.color, icon: UI.icon(typ.icon), size: 34 })
.addTo(_map)
.bindPopup(`
<b>${UI.icon(typ.icon)} ${typ.label}</b><br>
${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
<small>📍 ${distStr} entfernt</small><br>
<small>📅 ${_fmtDate(r.created_at)}</small>
${r.bestaetigt ? '<br><small><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</small>' : ''}
`);
marker.on('click', () => _openDetail(r));
_markers.push(marker);
});
}
// ----------------------------------------------------------
// LISTE
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('poison-list');
if (!listEl) return;
if (_reports.length === 0) {
listEl.innerHTML = UI.emptyState({
icon : UI.icon('check-circle'),
title : 'Alles sicher',
text : 'In deiner Nähe (10 km) gibt es aktuell keine Giftköder-Meldungen.',
action: `<button class="btn btn-danger" id="poison-empty-report">⚠️ Trotzdem melden</button>`,
});
listEl.querySelector('#poison-empty-report')
?.addEventListener('click', _showReportForm);
return;
}
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
listEl.querySelectorAll('[data-poison-id]').forEach(card => {
card.addEventListener('click', () => {
const r = _reports.find(x => x.id === parseInt(card.dataset.poisonId));
if (r) _openDetail(r);
});
});
listEl.querySelectorAll('.poison-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = parseInt(btn.dataset.poisonNoteId);
_openNoteModal('poison', id, 'Giftköder-Meldung ' + id, null);
});
});
}
function _reportCard(r) {
const typ = TYPEN[r.typ] || TYPEN.unbekannt;
const distStr = r.distanz_m < 1000
? `${r.distanz_m} m`
: `${(r.distanz_m / 1000).toFixed(1)} km`;
return `
<div class="card" data-poison-id="${r.id}"
style="cursor:pointer;margin-bottom:var(--space-3);
border-left:4px solid ${typ.color}">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<div style="width:40px;height:40px;flex-shrink:0;color:${typ.color};display:flex;align-items:center;justify-content:center">${UI.icon(typ.icon)}</div>
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span class="badge"
style="background:${typ.color};color:#fff">${typ.label}</span>
${r.bestaetigt
? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>'
: ''}
<span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap">
${distStr}
</span>
</div>
${r.beschreibung
? `<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)">
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>`
: ''}
<div class="text-xs-secondary">
Gemeldet ${_fmtDate(r.created_at)} ·
läuft ab ${_fmtDate(r.expires_at)}
</div>
</div>
</div>
${_appState.user ? `<div style="margin-top:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs poison-note-btn"
data-poison-note-id="${r.id}"
title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
</div>` : ''}
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
loading="lazy"
style="width:100%;max-height:160px;object-fit:cover;
border-radius:var(--radius-sm);margin-top:var(--space-2)">`
: ''}
</div>
`;
}
// ----------------------------------------------------------
// DETAIL-MODAL
// ----------------------------------------------------------
function _openDetail(r) {
const typ = TYPEN[r.typ] || TYPEN.unbekannt;
const isOwnEntry = _appState.user && _appState.user.id === r.user_id;
const isAdmin = _appState.user?.rolle === 'admin';
const body = `
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: ''}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge" style="background:${typ.color};color:#fff">
${UI.icon(typ.icon)} ${typ.label}
</span>
${r.bestaetigt ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''}
</div>
${r.beschreibung
? `<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">${UI.escape(r.beschreibung)}</p>`
: ''}
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
<div>⏰ Läuft ab: ${_fmtDate(r.expires_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${UI.escape(r.melder_name)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
${!r.bestaetigt && _appState.user && !isOwnEntry
? `<button class="btn btn-secondary flex-1" id="detail-confirm"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigen</button>`
: ''}
<button class="btn btn-secondary flex-1" id="detail-show-map">🗺️ Auf Karte</button>
${isOwnEntry || isAdmin
? `<button class="btn btn-nature flex-1" id="detail-resolve">✔ Erledigt</button>`
: ''}
</div>
`;
UI.modal.open({ title: `${UI.icon(typ.icon)} Giftköder-Meldung`, body });
document.getElementById('detail-confirm')?.addEventListener('click', async () => {
try {
const updated = await API.poison.confirm(r.id);
const idx = _reports.findIndex(x => x.id === r.id);
if (idx !== -1) _reports[idx] = { ..._reports[idx], ...updated };
UI.toast.success('Meldung bestätigt. Danke!');
UI.modal.close();
_renderMarkers();
_renderList();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Bestätigen.');
}
});
document.getElementById('detail-show-map')?.addEventListener('click', () => {
UI.modal.close();
if (_map) {
_map.setView([r.lat, r.lon], 16);
document.getElementById('poison-map')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Popup des Markers öffnen
const marker = _markers[_reports.findIndex(x => x.id === r.id)];
marker?.openPopup();
}
});
document.getElementById('detail-resolve')?.addEventListener('click', () => {
_showResolveDialog(r);
});
}
// ----------------------------------------------------------
// ERLEDIGT-DIALOG — mit Grundauswahl für KI-Analyse
// ----------------------------------------------------------
function _showResolveDialog(r) {
UI.modal.open({
title: '✔ Meldung als erledigt markieren',
body: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Die Meldung wird inaktiv gesetzt. Die Daten bleiben für spätere
Musteranalysen gespeichert.
</p>
<div class="form-group">
<label class="form-label">Grund</label>
<select class="form-control" id="resolve-grund">
<option value="beseitigt">Gefahr wurde beseitigt</option>
<option value="fehlerhaft">❌ Meldung war fehlerhaft</option>
<option value="anderes">💬 Anderer Grund</option>
</select>
</div>
`,
footer: `
<button class="btn btn-secondary" id="resolve-cancel">Abbrechen</button>
<button class="btn btn-nature" id="resolve-confirm">Erledigt markieren</button>
`,
});
document.getElementById('resolve-cancel')
?.addEventListener('click', UI.modal.close);
document.getElementById('resolve-confirm')
?.addEventListener('click', async () => {
const grund = document.getElementById('resolve-grund')?.value || 'beseitigt';
const btn = document.getElementById('resolve-confirm');
await UI.asyncButton(btn, async () => {
await API.poison.resolve(r.id, { grund });
_reports = _reports.filter(x => x.id !== r.id);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
App.checkNearbyAlerts();
UI.modal.close();
UI.toast.success('Meldung als erledigt markiert.');
});
});
}
// ----------------------------------------------------------
// MELDE-FORMULAR
// ----------------------------------------------------------
function _showReportForm() {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.');
App.navigate('settings');
return;
}
const typOpts = Object.entries(TYPEN)
.map(([val, { label }]) =>
`<option value="${val}">${label}</option>`)
.join('');
const body = `
<form id="poison-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Art des Fundes</label>
<select class="form-control" name="typ">${typOpts}</select>
</div>
<div class="form-group">
<label class="form-label">Standort</label>
<div id="poison-location-picker"></div>
</div>
<div class="form-group">
<label class="form-label">
Beschreibung
<span class="text-secondary">(optional)</span>
</label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="z. B. Wurstköder mit Nadeln, liegt beim Eingang Hundeparkplatz, linke Seite…"></textarea>
</div>
<div class="form-group">
<label class="form-label">
Foto
<span class="text-secondary">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
<img id="pf-photo-preview"
style="display:none;width:100%;max-height:200px;object-fit:cover;
border-radius:var(--radius-md);margin-top:var(--space-2)">
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="pf-cancel">Abbrechen</button>
<button type="submit" form="poison-form" class="btn btn-danger">
Absenden
</button>
`;
UI.modal.open({ title: '⚠️ Giftköder melden', body, footer });
// Location-Picker initialisieren + ggf. bekannten Standort vorausfüllen
const _picker = UI.locationPicker({ containerId: 'poison-location-picker' });
if (_userPos) {
_picker.setValue(_userPos.lat, _userPos.lon, null);
}
// Foto-Vorschau
const photoInput = document.querySelector('#poison-form [name="photo"]');
const photoPreview = document.getElementById('pf-photo-preview');
if (photoInput && photoPreview) {
UI.setupPhotoPreview(photoInput, photoPreview);
photoInput.addEventListener('change', () => {
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
});
}
document.getElementById('pf-cancel')
?.addEventListener('click', UI.modal.close);
// Formular absenden
document.getElementById('poison-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="poison-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const loc = _picker.getValue();
if (!loc.lat || !loc.lon) {
UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).');
return;
}
await UI.asyncButton(submitBtn, async () => {
const payload = {
lat : loc.lat,
lon : loc.lon,
typ : fd.typ,
beschreibung : fd.beschreibung || null,
};
const created = await API.poison.report(payload);
// SW hat Request in Queue gelegt (offline)
if (created?._queued) {
_showPoisonThanks(true);
return;
}
// Foto hochladen
if (photoInput?.files[0]) {
try {
const toUpload = await API.compressImage(photoInput.files[0]);
const formData = new FormData();
formData.append('file', toUpload);
const media = await API.poison.uploadPhoto(created.id, formData);
created.foto_url = media.foto_url;
} catch {
UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.');
}
}
// Distanz client-seitig berechnen
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
: 0;
_reports.unshift(created);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
App.checkNearbyAlerts();
App.callModule('map', 'refresh');
_showPoisonThanks(false);
});
});
}
// ----------------------------------------------------------
// DANKE-OVERLAY nach Giftköder-Meldung
// ----------------------------------------------------------
function _showPoisonThanks(isQueued) {
const offlineNote = isQueued
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#wifi-slash"></use></svg>
Wird synchronisiert sobald du wieder online bist.
</p>`
: '';
UI.modal.open({
title: 'Danke für deine Meldung!',
body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<div class="mb-4">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg>
</div>
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0">
Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.
</p>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);
margin:var(--space-2) 0 0;line-height:1.5;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Vielen Dank, dass du die Community schützt!
</p>
${offlineNote}
</div>
`,
footer: `<button class="btn btn-primary flex-1" id="poison-thanks-ok">OK</button>`,
});
document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close);
setTimeout(() => UI.modal.close(), 5000);
}
// ----------------------------------------------------------
// BADGE (Sidebar + Bottom-Nav)
// ----------------------------------------------------------
function _updateBadge(count) {
const b1 = document.getElementById('poison-badge');
const b2 = document.getElementById('poison-nav-badge');
if (b1) { b1.textContent = count; b1.style.display = count > 0 ? '' : 'none'; }
if (b2) { b2.textContent = count; b2.classList.toggle('hidden', count === 0); }
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
// Haversine client-seitig (für frisch gemeldete Einträge)
function _haversine(lat1, lon1, lat2, lon2) {
const R = 6_371_000;
const p1 = lat1 * Math.PI / 180;
const p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
// _fmtDate: bewusst lokal behalten — UI.time.format() liefert langen Monats-Namen
// und behandelt kein SQLite-Leerzeichen-Format ("2026-04-12 00:00:00")
function _fmtDate(isoStr) {
if (!isoStr) return '';
// SQLite speichert "2026-04-12T00:00:00" oder "2026-04-12 00:00:00"
const d = new Date(isoStr.replace(' ', 'T'));
return d.toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
}
// ----------------------------------------------------------
// NOTIZ-MODAL
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, 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.');
UI.setLoading(saveBtn, false);
}
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew, openDetail: _openDetail };
})();