Sprint 15: Zeitzone-Fix, Gewichts-Sync, Öffnungszeiten, KI-Bericht, POI-Moderation — SW by-v432, APP_VER 411

- client_time: Browser-Lokalzeit bei allen Creates mitschicken (Tagebuch, Notizen,
  Forum, Verlorener Hund, Routen) — kein UTC-Versatz mehr bei Einträgen
- Gewicht-Sync: health typ=gewicht schreibt dogs.gewicht_kg, einmalige Migration
- Praxen: opening_hours + lat/lon/osm_id in tieraerzte-Tabelle, OSM-Nearby-Lookup,
  Öffnungszeiten in Karte und Detailansicht
- KI-Gesundheitsbericht: alle 2 Wochen automatisch, ki_health_reports-Tabelle,
  Frontend-Banner mit Archiv (letzten 5 Berichte)
- POI-Korrekturen: User schlägt Öffnungszeiten-Änderung vor, Moderatoren-Tab
  genehmigt/lehnt ab, user_edited-Flag schützt vor Overpass-Überschreibung
- timeutils.py: safe_client_time() zentral für alle Routen
This commit is contained in:
rene 2026-04-26 15:38:50 +02:00
parent 679dbdd862
commit 06bd8525ed
21 changed files with 724 additions and 75 deletions

View file

@ -14,6 +14,7 @@ window.Page_moderation = (() => {
{ id: 'fotos', label: 'Fotos', icon: 'image' },
{ id: 'user', label: 'User', icon: 'users' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'poi-edits', label: 'POI-Edits', icon: 'clock' },
];
// ------------------------------------------------------------------
@ -77,6 +78,7 @@ window.Page_moderation = (() => {
case 'fotos': await _renderFotos(el); break;
case 'user': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'poi-edits': await _renderPoiEdits(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -106,6 +108,10 @@ window.Page_moderation = (() => {
'Züchter ausstehend',
s.pending_zuchter,
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('clock',
'POI-Korrekturen',
s.pending_poi_edits ?? 0,
(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
</div>
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)">
@ -456,6 +462,76 @@ window.Page_moderation = (() => {
`;
}
// ------------------------------------------------------------------
// TAB: POI-KORREKTUREN
// ------------------------------------------------------------------
async function _renderPoiEdits(el) {
const edits = await API.get('/moderation/poi-edits');
if (!edits.length) {
el.innerHTML = _emptyState('check-circle', 'Alles erledigt', 'Keine ausstehenden POI-Korrekturen.');
return;
}
const STATUS_LABEL = { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' };
const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' };
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${edits.map(e => `
<div class="card" style="padding:var(--space-4)" data-edit-id="${e.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap">
<div>
<div style="font-weight:600">${_esc(e.poi_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
· ${new Date(e.created_at).toLocaleDateString('de-DE')}
</div>
</div>
<span style="font-size:var(--text-xs);font-weight:600;color:${STATUS_COLOR[e.status] || 'inherit'}">
${STATUS_LABEL[e.status] || e.status}
</span>
</div>
<div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div>
<div style="font-size:var(--text-sm)">${_esc(e.old_value) || '<em style="color:var(--c-text-muted)">leer</em>'}</div>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_esc(e.new_value)}</div>
</div>
</div>
${e.status === 'pending' ? `
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm flex-1" data-action="approve" data-id="${e.id}">
Übernehmen
</button>
<button class="btn btn-secondary btn-sm flex-1" data-action="reject" data-id="${e.id}">
Ablehnen
</button>
</div>` : ''}
</div>
`).join('')}
</div>
`;
el.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const action = btn.dataset.action;
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${id}`, { action });
UI.toast.success(action === 'approve' ? 'Übernommen!' : 'Abgelehnt.');
await _renderPoiEdits(el);
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
btn.disabled = false;
}
});
});
}
function _esc(s) {
if (!s) return '';
return String(s)