banyaro/backend/static/js/pages/health.js
rene c03884cb81 Perf: 9 Performance-Fixes — SW by-v1072
Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
2026-05-26 06:30:36 +02:00

3389 lines
161 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 — Gesundheit & Impfpass (Sprint 3)
Tabs: Impfungen | Tierarzt | Gewicht | Medikamente | Allergien | Dokumente
+ KI-Gesundheitszusammenfassung
============================================================ */
window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _favoritVet = null;
let _healthDocs = [];
const BASE_TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
{ key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'versicherung', label: 'Versicherung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield-check"></use></svg>' },
{ key: 'verhalten', label: 'Verhalten', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#brain"></use></svg>' },
];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
function _getTabs() {
const tabs = [...BASE_TABS];
if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB);
return tabs;
}
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState, params) {
_container = container;
_appState = appState;
if (params?.tab) {
const valid = _getTabs().some(t => t.key === params.tab);
if (valid) _activeTab = params.tab;
}
await _render();
if (params?.openForm) {
setTimeout(() => _showForm(null, _activeTab), 200);
}
}
async function refresh() {
if (!_appState.activeDog) return;
_data = {};
await _renderHealth();
}
async function onDogChange() {
_data = {};
await _renderHealth();
}
function openNew() {
_showForm(null, _activeTab);
}
// ----------------------------------------------------------
// RENDER — Einstieg: Picker bei mehreren Hunden, sonst direkt
// ----------------------------------------------------------
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}
await _renderHealth();
}
// ----------------------------------------------------------
// HEALTH-ANSICHT — Tabs mit Einträgen
// ----------------------------------------------------------
async function _renderHealth() {
const dog = _appState.activeDog;
const transponderHtml = `
<div class="health-transponder" id="health-transponder">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg>
<span class="health-transponder-label">Transponder:</span>
<span class="health-transponder-nr" id="health-transponder-nr">
${dog?.chip_nr ? `<strong>${_esc(dog.chip_nr)}</strong>` : '<em style="color:var(--c-text-muted)">nicht eingetragen</em>'}
</span>
<button class="btn btn-link btn-sm health-transponder-edit" id="health-transponder-edit"
style="padding:0;font-size:var(--text-xs);color:var(--c-text-muted)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>
</div>`;
_container.innerHTML = `
${UI.dogChip(_appState)}
<div class="by-toolbar health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
</button>
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></div>
<div id="health-reminders"></div>
<div id="health-reminders-banner" style="display:none;padding:0 0 var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
`;
_renderTabBar();
UI.bindDogChip(_container, _appState);
_loadRemindersBanner();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn')
.addEventListener('click', _showKiTierarzt);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
await _loadAll();
_renderErinnerungen();
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
_loadMeinTierarzt();
}
// ----------------------------------------------------------
// ERINNERUNGEN — Banner über den Tabs
// ----------------------------------------------------------
function _getErinnerungen() {
const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament', 'laeufigkeit'];
const now = Date.now();
const items = [];
REMINDER_TABS.forEach(typ => {
(_data[typ] || []).forEach(e => {
if (!e.naechstes) return;
const tage = Math.ceil((new Date(e.naechstes).getTime() - now) / 86400000);
if (tage <= 60) items.push({ ...e, _tage: tage, _typ: typ });
});
});
return items.sort((a, b) => a._tage - b._tage);
}
function _renderErinnerungen() {
const el = _container.querySelector('#health-reminders');
if (!el) return;
const items = _getErinnerungen();
// Nav-Badge aktualisieren (Anzahl überfälliger/bald fälliger Einträge)
const overdueCount = items.filter(e => e._tage < 0).length;
_updateHealthBadge(overdueCount || (items.length ? items.length : 0));
if (!items.length) { el.innerHTML = ''; return; }
const ICONS = {
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bandaids"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>',
tierarzt: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>',
gewicht: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>',
allergie: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-circle"></use></svg>',
laeufigkeit: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>',
};
el.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Anstehende Erinnerungen
</div>
${items.map(e => {
const ampel = _impfAmpel(e.naechstes);
const dateStr = UI.time.format(e.naechstes + 'T00:00:00');
const ageLabel = e._tage < 0
? `Überfällig seit ${Math.abs(e._tage)} Tagen`
: e._tage === 0 ? 'Heute fällig'
: `In ${e._tage} Tagen`;
return `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-1);
background:var(--c-surface);border-radius:var(--radius-md);
border-left:3px solid ${ampel.color === 'red' ? '#ef4444' : ampel.color === 'yellow' ? '#f59e0b' : '#22c55e'}">
<span style="font-size:1.2rem">${ICONS[e._typ] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>'}</span>
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-medium);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(e.bezeichnung)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${ageLabel} · ${dateStr}
</div>
</div>
<button class="btn btn-sm ${e._tage < 0 ? 'btn-primary' : 'btn-secondary'}"
data-action="reminder-erledigt"
data-entry-id="${e.id}" data-entry-typ="${e._typ}"
style="flex-shrink:0;white-space:nowrap">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Erledigt
</button>
</div>`;
}).join('')}
</div>
`;
el.querySelectorAll('[data-action="reminder-erledigt"]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.entryId);
const typ = btn.dataset.entryTyp;
const entry = (_data[typ] || []).find(e => e.id === id);
if (!entry) return;
// Neues Formular ohne id → Neu-Eintrag, vorausgefüllt
const today = new Date().toISOString().slice(0, 10);
let naechstes = '';
if (entry.intervall_tage) {
const next = new Date();
next.setDate(next.getDate() + entry.intervall_tage);
naechstes = next.toISOString().slice(0, 10);
}
_showForm({
bezeichnung: entry.bezeichnung,
datum: today,
naechstes,
intervall_tage: entry.intervall_tage,
tierarzt_id: entry.tierarzt_id,
tierarzt_name: entry.tierarzt_name,
charge_nr: entry.charge_nr,
}, typ);
});
});
}
function _updateHealthBadge(count) {
['[data-page="health"] .nav-item-icon',
'[data-page="health"] .sidebar-item-icon'].forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
let badge = el.querySelector('.nav-badge');
if (count > 0) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'nav-badge';
el.appendChild(badge);
}
badge.textContent = count > 9 ? '9+' : count;
} else if (badge) {
badge.remove();
}
});
});
}
function _renderTabBar() {
const tabsEl = _container.querySelector('#by-tabs');
tabsEl.innerHTML = _getTabs().map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>
`).join('');
const count = tabsEl.querySelectorAll('.by-tab').length;
tabsEl.style.setProperty('--health-tab-cols', Math.ceil(count / 2));
tabsEl.querySelectorAll('.by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
tabsEl.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderTab();
});
});
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadAll() {
const dogId = _appState.activeDog.id;
try {
const all = await API.health.list(dogId);
_data = {};
_getTabs().forEach(t => { _data[t.key] = []; });
_data['laeufigkeit'] = _data['laeufigkeit'] || [];
all.forEach(e => {
if (_data[e.typ] !== undefined) _data[e.typ].push(e);
});
} catch (err) {
UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.');
}
try {
_praxen = await API.tieraerzte.list();
} catch (err) {
// silent fail
}
try {
_data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId);
} catch (err) {
_data['gewicht_chart'] = [];
}
try {
_favoritVet = await API.tieraerzte.myFavorite();
} catch (err) {
_favoritVet = null;
}
try {
_healthDocs = await API.healthDocs.list(dogId);
} catch (err) {
_healthDocs = [];
}
}
// ----------------------------------------------------------
// TAB-INHALT RENDERN
// ----------------------------------------------------------
function _renderTab() {
const content = _container.querySelector('#by-tab-content');
if (!content) return;
const entries = _data[_activeTab] || [];
switch (_activeTab) {
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'versicherung': _renderVersicherung(content); break;
case 'verhalten': _renderVerhalten(content); break;
}
_bindTabEvents(content);
}
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status
// ----------------------------------------------------------
function _renderImpfungen(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
if (!entries.length) return _emptyState(
'syringe',
'Noch keine Impfungen',
`Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
addBtn
);
const items = entries.map(e => {
const ampel = _impfAmpel(e.naechstes);
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
const vetName = praxis?.name || e.tierarzt_name || '';
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-ampel ampel-${ampel.color}" title="${ampel.label}"></div>
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
</div>
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(vetName)}</div>` : ''}
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`;
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
function _impfAmpel(naechstesStr) {
if (!naechstesStr) return { color: 'grey', label: 'Kein Folgedatum', icon: '' };
const diff = (new Date(naechstesStr) - Date.now()) / 86400000; // Tage
if (diff < 0) return { color: 'red', label: 'Überfällig!', icon: '🔴' };
if (diff < 60) return { color: 'yellow', label: 'Bald fällig', icon: '🟡' };
return { color: 'green', label: 'Aktuell', icon: '🟢' };
}
// ----------------------------------------------------------
// TIERARZTBESUCHE
// ----------------------------------------------------------
function _renderTierarzt(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
});
const items = entries.map(e => {
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
const praxisName = praxis?.name || e.tierarzt_name || '';
const praxisOrt = praxis ? [praxis.plz, praxis.ort].filter(Boolean).join(' ') : '';
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${e.kosten != null ? ` · ${Number(e.kosten).toFixed(2)}` : ''}
</div>
${praxisName ? `
<div style="display:flex;align-items:center;gap:var(--space-1);
margin-top:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
</div>` : ''}
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`;
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// GEWICHT — Großanzeige + SVG-Diagramm
// ----------------------------------------------------------
function _renderGewicht(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>', title: 'Noch keine Gewichtseinträge', action: addBtn
});
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
const latest = sorted[sorted.length - 1];
const prev = sorted.length >= 2 ? sorted[sorted.length - 2] : null;
const delta = prev ? (parseFloat(latest.wert) - parseFloat(prev.wert)) : null;
const deltaHtml = delta !== null ? (() => {
const sign = delta > 0 ? '+' : '';
const color = delta > 0 ? 'var(--c-warning)' : delta < 0 ? 'var(--c-success)' : 'var(--c-text-muted)';
const arrow = delta > 0 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trend-up"></use></svg>' : delta < 0 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trend-down"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-right"></use></svg>';
return `<div style="font-size:var(--text-sm);color:${color};margin-top:var(--space-1)">
${arrow} ${sign}${delta.toFixed(1)} kg seit letzter Messung
</div>`;
})() : '';
const chartEntries = _data['gewicht_chart'] || [];
const chart = _renderWeightChart(chartEntries);
const items = sorted.slice().reverse().map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry"
style="padding:var(--space-3) var(--space-4)">
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${UI.time.format(e.datum + 'T00:00:00')}
</span>
<span style="font-weight:var(--weight-bold);font-size:var(--text-lg)">
${e.wert} <span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${e.einheit || 'kg'}</span>
</span>
</div>
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Gewicht ${_esc(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
`).join('');
return `
<div style="text-align:center;padding:var(--space-5) var(--space-4) var(--space-2)">
<div style="font-size:3rem;font-weight:var(--weight-bold);color:var(--c-text);line-height:1">
${latest.wert}
<span style="font-size:1.1rem;font-weight:var(--weight-normal);color:var(--c-text-secondary)">kg</span>
</div>
${deltaHtml}
</div>
${chart ? `<div class="health-chart-wrap">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3) 0;display:flex;align-items:center;gap:var(--space-1)">
Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
</div>
${chart}
</div>` : ''}
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
}
function _weightChart(entries) {
const W = 340, H = 160, PX = 36, PY = 16, PB = 28;
const vals = entries.map(e => parseFloat(e.wert));
const minV = Math.min(...vals);
const maxV = Math.max(...vals);
const range = maxV - minV || 1;
const innerW = W - PX * 2;
const innerH = H - PY - PB;
const toX = i => PX + (i / (entries.length - 1)) * innerW;
const toY = v => PY + (1 - (v - minV) / range) * innerH;
const pts = entries.map((e, i) => [toX(i), toY(parseFloat(e.wert))]);
// Smooth bezier path
const linePath = pts.reduce((acc, [x, y], i) => {
if (i === 0) return `M ${x},${y}`;
const [px, py] = pts[i - 1];
const cpx = (px + x) / 2;
return `${acc} C ${cpx},${py} ${cpx},${y} ${x},${y}`;
}, '');
const fillPath = `${linePath} L ${pts[pts.length - 1][0]},${H - PB} L ${pts[0][0]},${H - PB} Z`;
// Grid lines
const gridLines = [0, 0.5, 1].map(frac => {
const v = maxV - frac * range;
const y = toY(v);
return `
<line x1="${PX}" y1="${y}" x2="${W - PX}" y2="${y}"
stroke="var(--c-border-light)" stroke-width="1" stroke-dasharray="4,4"/>
<text x="${PX - 5}" y="${y + 4}" font-size="9" fill="var(--c-text-muted)" text-anchor="end">
${v.toFixed(1)}
</text>`;
}).join('');
// X-axis labels
const xIdxs = entries.length <= 3
? entries.map((_, i) => i)
: [0, Math.round((entries.length - 1) / 2), entries.length - 1];
const xLabels = [...new Set(xIdxs)].map(i => {
const [m, d] = entries[i].datum.slice(5).split('-');
return `<text x="${toX(i)}" y="${H - 6}" font-size="9" fill="var(--c-text-muted)"
text-anchor="middle">${d}.${m}.</text>`;
}).join('');
// Dots
const dots = pts.map(([x, y], i) => {
const isLast = i === pts.length - 1;
return `<circle cx="${x}" cy="${y}" r="${isLast ? 5 : 3.5}"
fill="${isLast ? 'var(--c-primary)' : 'var(--c-surface)'}"
stroke="var(--c-primary)" stroke-width="2"/>`;
}).join('');
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
return `
<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">
<defs>
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.22"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
</linearGradient>
</defs>
${gridLines}
<path d="${fillPath}" fill="url(#${gId})"/>
<path d="${linePath}" fill="none" stroke="var(--c-primary)"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
${dots}
${xLabels}
</svg>
`;
}
// ----------------------------------------------------------
// GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht)
// ----------------------------------------------------------
function _renderWeightChart(entries) {
// entries: [{datum, gewicht}, ...]
if (!entries || entries.length < 2) {
return '<p class="health-chart-empty">Mindestens 2 Gewichtseinträge für den Verlauf nötig.</p>';
}
const W = 300, H = 120, PAD = 24;
const weights = entries.map(e => e.gewicht);
const min = Math.min(...weights), max = Math.max(...weights);
const range = max - min || 1;
// x: gleichmäßig verteilt, y: normalisiert
const pts = entries.map((e, i) => {
const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD);
const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD);
return { x, y, ...e };
});
const polyline = pts.map(p => `${p.x},${p.y}`).join(' ');
const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`;
// Datenpunkte + Tooltips als title-Elemente
const circles = pts.map(p =>
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--c-primary)">
<title>${p.datum}: ${p.gewicht} kg</title>
</circle>`
).join('');
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
return `
<div class="health-chart-wrap">
<div class="health-chart-title">Gewichtsverlauf</div>
<svg viewBox="0 0 ${W} ${H}" class="health-chart-svg" aria-label="Gewichtsverlauf">
<defs>
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity=".25"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon points="${area}" fill="url(#${gId})"/>
<polyline points="${polyline}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
${circles}
<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${min}</text>
<text x="${PAD - 2}" y="${PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${max}</text>
</svg>
<div class="health-chart-labels">
<span>${entries[0].datum}</span>
<span>${entries[entries.length - 1].datum}</span>
</div>
</div>
`;
}
// ----------------------------------------------------------
// LÄUFIGKEIT — Timeline + Vorhersage
// ----------------------------------------------------------
function _renderLaeufigkeit(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">${UI.icon('plus')} Läufigkeit eintragen</button>`;
const sorted = [...entries].sort((a, b) => b.datum.localeCompare(a.datum)); // neueste zuerst
// Durchschnittlicher Abstand berechnen
let avgInterval = null;
if (sorted.length >= 2) {
const asc = [...sorted].reverse();
const diffs = [];
for (let i = 1; i < asc.length; i++) {
diffs.push(Math.round((new Date(asc[i].datum) - new Date(asc[i-1].datum)) / 86400000));
}
avgInterval = Math.round(diffs.reduce((a, b) => a + b, 0) / diffs.length);
}
// Nächste vorhergesagte Läufigkeit
const last = sorted[0];
let nextPrediction = null;
if (last?.naechstes) {
nextPrediction = last.naechstes;
} else if (last?.datum && (last?.intervall_tage || avgInterval)) {
const iv = last.intervall_tage || avgInterval;
const d = new Date(last.datum);
d.setDate(d.getDate() + iv);
nextPrediction = d.toISOString().slice(0, 10);
}
// Banner für nächste Läufigkeit
let banner = '';
if (nextPrediction) {
const ampel = _impfAmpel(nextPrediction);
const tage = Math.ceil((new Date(nextPrediction) - Date.now()) / 86400000);
const label = tage < 0 ? `Überfällig seit ${Math.abs(tage)} Tagen`
: tage === 0 ? 'Könnte heute beginnen'
: tage <= 14 ? `In ${tage} Tagen`
: UI.time.format(nextPrediction + 'T00:00:00');
banner = `
<div style="margin:var(--space-4) var(--space-4) 0;padding:var(--space-3) var(--space-4);
background:var(--c-surface);border-radius:var(--radius-lg);
border-left:4px solid ${ampel.color === 'red' ? 'var(--c-danger)' : ampel.color === 'yellow' ? 'var(--c-warning)' : 'var(--c-primary)'};
display:flex;align-items:center;gap:var(--space-3)">
<span style="font-size:1.5rem">${UI.icon('gender-female')}</span>
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Nächste Läufigkeit erwartet</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${label}
${avgInterval ? ` · Ø ${avgInterval} Tage Abstand` : ''}
</div>
</div>
</div>`;
}
if (!sorted.length) return `
${UI.emptyState({
icon: UI.icon('gender-female'),
title: 'Noch keine Läufigkeit eingetragen',
text: 'Trage Läufigkeiten ein, um den Zyklus zu verfolgen.',
action: addBtn,
})}`;
const items = sorted.map((e, i) => {
const prev = sorted[i + 1];
const interval = prev
? Math.round((new Date(e.datum) - new Date(prev.datum)) / 86400000)
: null;
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
display:flex;align-items:center;justify-content:center;
flex-shrink:0;color:var(--c-text-inverse)">
${UI.icon('gender-female')}
</div>
<div class="health-card-body">
<div class="health-card-title">Läufigkeit · ${UI.time.format(e.datum + 'T00:00:00')}</div>
<div class="health-card-meta">
${e.wert ? `Dauer: ${e.wert} Tage` : 'Dauer nicht angegeben'}
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Läufigkeit ${_esc(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>`;
}).join('');
return `
${banner}
<div class="health-list" style="margin-top:var(--space-4)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// MEDIKAMENTE
// ----------------------------------------------------------
function _renderMedikamente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Medikament eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Medikamente', action: addBtn
});
const aktive = entries.filter(e => e.aktiv);
const inaktive = entries.filter(e => !e.aktiv);
const renderGroup = (items, label) => items.length ? `
<div class="by-section-label">${label}</div>
${items.map(e => `
<div class="health-card${e.aktiv ? '' : ' health-card--inactive'}" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${e.dosierung ? _esc(e.dosierung) : ''}
${e.haeufigkeit ? ` · ${_esc(e.haeufigkeit)}` : ''}
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('')}
` : '';
return `
<div class="health-list">
${renderGroup(aktive, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Aktuelle Medikamente')}
${renderGroup(inaktive, 'Vergangene Medikamente')}
</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
}
// ----------------------------------------------------------
// ALLERGIEN
// ----------------------------------------------------------
function _renderAllergien(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Allergie eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', title: 'Noch keine Allergien eingetragen', action: addBtn
});
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
const items = entries.map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">
${e.schweregrad ? SCHWEREGRAD[e.schweregrad] || '' : ''} ${_esc(e.bezeichnung)}
</div>
<div class="health-card-meta">
Erstmals: ${UI.time.format(e.datum + 'T00:00:00')}
${e.schweregrad ? ` · Schweregrad: ${_esc(e.schweregrad)}` : ''}
</div>
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// DOKUMENTE
// ----------------------------------------------------------
function _renderDokumente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
});
const items = entries.map(e => {
// media_items bevorzugen, legacy datei_url als Fallback
const mediaList = e.media_items?.length
? e.media_items
: (e.datei_url ? [{ id: null, url: e.datei_url, media_type: e.datei_typ || 'image' }] : []);
const firstImg = mediaList.find(m => m.media_type !== 'pdf');
const hasPdf = mediaList.some(m => m.media_type === 'pdf');
const count = mediaList.length;
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
${firstImg
? `<img src="${_esc(firstImg.url)}" class="health-doc-thumb" alt="Vorschau"
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${count > 1 ? ` · ${count} Dateien` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
${count
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF
</a>`
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild
</a>`
).join('')}
</div>`
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
</div>
</div>
`;
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
${_renderBefundeSection()}`;
}
// ----------------------------------------------------------
// EVENTS BINDEN
// ----------------------------------------------------------
function _bindTabEvents(content) {
content.querySelectorAll('[data-action="add-entry"]').forEach(btn => {
btn.addEventListener('click', () => _showForm(null, _activeTab));
});
content.querySelectorAll('[data-action="open-entry"]').forEach(card => {
const id = parseInt(card.dataset.id);
const entry = (_data[_activeTab] || []).find(e => e.id === id);
if (entry) card.addEventListener('click', () => _openDetail(entry));
});
content.querySelectorAll('[data-action="open-note"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = parseInt(btn.dataset.entryId);
const label = btn.dataset.label || '';
_openNoteModal('health', id, label, null);
});
});
// Praxis öffnen → Detail-Modal mit Bewertungen
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
el.addEventListener('click', () => {
const id = parseInt(el.dataset.praxisId);
const p = _praxen.find(x => x.id === id);
if (p) _showPraxisDetail(p);
});
});
// Praxis bearbeiten
content.querySelectorAll('[data-action="edit-praxis"]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.praxisId);
const p = _praxen.find(x => x.id === id);
if (p) _showPraxForm(p);
});
});
// Bewertung abgeben
content.querySelectorAll('[data-action="bewerten"]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.praxisId);
const p = _praxen.find(x => x.id === id);
if (p) _showBewertungModal(p);
});
});
// Dokument löschen
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const dogId = _appState.activeDog.id;
const ok = await UI.modal.confirm({
title: 'Dokument löschen',
text: 'Die Datei wird unwiderruflich gelöscht.',
confirmText: 'Löschen',
danger: true,
});
if (!ok) return;
await UI.asyncButton(btn, async () => {
await API.health.deleteDocument(dogId, id);
const list = _data[_activeTab] || [];
const entry = list.find(e => e.id === id);
if (entry) { entry.datei_url = null; entry.datei_typ = null; }
_renderTab();
UI.toast.success('Dokument gelöscht.');
});
});
});
// Praxis hinzufügen
content.querySelector('[data-action="add-praxis"]')
?.addEventListener('click', () => _showPraxForm(null));
// Favorit-Toggle für Praxen
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const vetId = parseInt(btn.dataset.praxisId);
await UI.asyncButton(btn, async () => {
const res = await API.tieraerzte.toggleFavorite(vetId);
if (res.is_favorite) {
_favoritVet = _praxen.find(p => p.id === vetId) || null;
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
} else {
_favoritVet = null;
UI.toast.success('Favorit entfernt.');
}
// is_favorite in _praxen aktualisieren
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
const elFav = _container.querySelector('#health-mein-tierarzt');
if (elFav) _renderMeinTierarztKachel(elFav);
_renderTab();
});
});
});
// Befunde & Dokumente
if (_activeTab === 'dokument') {
_bindBefundeEvents(content);
}
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// ----------------------------------------------------------
function _openDetail(entry) {
const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0];
const fields = _detailFields(entry);
// Media-Items zusammenstellen (neue + legacy)
const mediaItems = entry.media_items?.length
? entry.media_items
: (entry.datei_url ? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image' }] : []);
const mediaHtml = mediaItems.length
? `<div class="health-media-gallery" style="margin-top:var(--space-4)">
${mediaItems.map(m => m.media_type === 'pdf'
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm health-media-gallery-pdf">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen
</a>`
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener" class="health-media-gallery-img">
<img src="${_esc(m.url)}" alt="Bild" loading="lazy">
</a>`
).join('')}
</div>`
: '';
const body = `
<div class="health-detail">
${fields}
${mediaHtml}
</div>
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
`;
const modalTitle = entry.typ === 'gewicht'
? `${tabInfo.icon} ${entry.wert} ${entry.einheit || 'kg'}`
: entry.typ === 'laeufigkeit'
? `${tabInfo.icon} Läufigkeit ${UI.time.format(entry.datum + 'T00:00:00')}`
: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`;
UI.modal.open({ title: modalTitle, body });
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_showForm(entry, entry.typ);
});
}
function _detailFields(e) {
const rows = [];
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.wert) rows.push(['Dauer', `${e.wert} Tage`]);
if (e.naechstes) rows.push([e.typ === 'laeufigkeit' ? 'Nächste erwartet' : 'Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.deckdatum) rows.push(['Deckdatum', UI.time.format(e.deckdatum + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.wurftermin) {
const wurfDate = new Date(e.wurftermin + 'T00:00:00');
const today = new Date(); today.setHours(0,0,0,0);
const diffDays = Math.round((wurfDate - today) / 86400000);
const zukunft = diffDays > 0 ? ` <span style="color:var(--c-primary);font-weight:600">in ${diffDays} Tagen</span>` : '';
rows.push(['Wurftermin', UI.time.format(e.wurftermin + 'T00:00:00') + zukunft]);
}
if (e.tierarzt_id) {
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
if (praxis) {
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
const oh = praxis.opening_hours ? `<br><small style="color:var(--c-text-secondary)">🕐 ${_esc(_fmtOeffnungszeiten(praxis.opening_hours))}</small>` : '';
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}${oh}`]);
}
} else if (e.tierarzt_name) {
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
}
if (e.charge_nr) rows.push(['Charge-Nr.', _esc(e.charge_nr)]);
if (e.kosten != null) rows.push(['Kosten', `${Number(e.kosten).toFixed(2)}`]);
if (e.diagnose) rows.push(['Diagnose', _esc(e.diagnose)]);
if (e.wert) rows.push(['Gewicht', `${e.wert} ${e.einheit || 'kg'}`]);
if (e.dosierung) rows.push(['Dosierung', _esc(e.dosierung)]);
if (e.haeufigkeit) rows.push(['Häufigkeit', _esc(e.haeufigkeit)]);
if (e.bis_datum) rows.push(['Bis', UI.time.format(e.bis_datum + 'T00:00:00')]);
if (e.schweregrad) rows.push(['Schweregrad',_esc(e.schweregrad)]);
if (e.reaktion) rows.push(['Reaktion', _esc(e.reaktion)]);
if (e.notiz) rows.push(['Notiz', _esc(e.notiz)]);
return `<dl class="health-detail-dl">${
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
}</dl>`;
}
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry, typ) {
const isEdit = !!(entry?.id);
const today = new Date().toISOString().slice(0, 10);
const t = typ || _activeTab;
const commonFields = `
${t !== 'gewicht' && t !== 'laeufigkeit' ? `
<div class="form-group">
<label class="form-label">Bezeichnung *</label>
<input class="form-control" type="text" name="bezeichnung"
value="${_esc(entry?.bezeichnung || '')}" required
placeholder="${_formPlaceholder(t)}">
</div>` : ''}
<div class="form-group">
<label class="form-label">${t === 'laeufigkeit' ? 'Beginn der Läufigkeit *' : 'Start *'}</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
`;
const extraFields = _extraFormFields(entry, t);
const notizField = `
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="3">${_esc(entry?.notiz || '')}</textarea>
</div>
`;
// Multi-Upload-Bereich — zeige vorhandene media_items + neuen Upload
const existingMedia = (entry?.media_items || []);
const legacyFile = (!existingMedia.length && entry?.datei_url)
? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image', _legacy: true }]
: [];
const allMedia = [...existingMedia, ...legacyFile];
const mediaThumbsHtml = allMedia.map(m => {
const isImg = m.media_type !== 'pdf';
const removeBtn = m.id
? `<button type="button" class="health-media-remove" data-media-id="${m.id}"
title="Entfernen" aria-label="Datei entfernen">×</button>`
: '';
return `<div class="health-media-thumb" data-media-id="${m.id || ''}">
${isImg
? `<img src="${_esc(m.url)}" alt="Vorschau">`
: `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div>`}
${removeBtn}
</div>`;
}).join('');
const uploadField = `
<div class="form-group" id="health-media-section">
<label class="form-label">
Dateien (Bilder / PDFs)
${UI.help('Befunde, Röntgenbilder, Laborwerte — mehrere Dateien möglich.')}
</label>
<div class="health-media-grid" id="health-media-grid">${mediaThumbsHtml}</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei hinzufügen
<input type="file" name="datei_neu" accept="image/*,.pdf" multiple
style="position:absolute;opacity:0;width:1px;height:1px" id="health-file-input">
</label>
<div id="health-file-pending" style="margin-top:var(--space-2);display:flex;flex-wrap:wrap;gap:var(--space-2)"></div>
</div>
`;
const body = `
<form id="health-form" autocomplete="off">
${commonFields}
${extraFields}
${notizField}
${uploadField}
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="health-form" class="btn btn-primary" style="width:100%">${isEdit ? 'Speichern' : 'Erstellen'}</button>
<div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="health-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
</div>
</div>
`;
const tabInfo = _getTabs().find(tab => tab.key === t) || BASE_TABS[0];
UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body, footer });
const form = document.getElementById('health-form');
setTimeout(() => {
form?.querySelector('[name="bezeichnung"]')?.focus();
// "Praxis anlegen" Button im Formular
form?.querySelector('[data-action="goto-praxen"]')?.addEventListener('click', () => {
UI.modal.close();
_activeTab = 'praxen';
_renderTab();
});
// File-Input: Vorschau für ausstehende Uploads
const fileInput = document.getElementById('health-file-input');
const pendingBox = document.getElementById('health-file-pending');
if (fileInput && pendingBox) {
fileInput.addEventListener('change', () => {
pendingBox.innerHTML = '';
Array.from(fileInput.files || []).forEach(f => {
const isPdf = f.name.toLowerCase().endsWith('.pdf');
const thumb = document.createElement('div');
thumb.className = 'health-media-thumb health-media-thumb--pending';
if (isPdf) {
thumb.innerHTML = `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div><small>${_esc(f.name.slice(0, 18))}</small>`;
} else {
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
thumb.appendChild(img);
}
pendingBox.appendChild(thumb);
});
});
}
// X-Buttons für vorhandene Media-Items
document.querySelectorAll('#health-media-grid .health-media-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const mediaId = parseInt(btn.dataset.mediaId);
const dogId = _appState.activeDog.id;
if (!mediaId || !entry?.id) return;
try {
await API.health.deleteMedia(dogId, entry.id, mediaId);
// Aus entry.media_items entfernen
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
btn.closest('.health-media-thumb').remove();
// Auch in _data aktualisieren
const list = _data[t] || [];
const idx = list.findIndex(x => x.id === entry.id);
if (idx !== -1) list[idx].media_items = (list[idx].media_items || []).filter(m => m.id !== mediaId);
UI.toast.success('Datei entfernt.');
} catch (err) {
UI.toast.error('Fehler beim Löschen.');
}
});
});
}, 150);
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('health-form-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Eintrag löschen?', message: 'Nicht rückgängig.', confirmText: 'Löschen', danger: true,
});
if (ok) {
try {
await API.health.delete(_appState.activeDog.id, entry.id);
_data[t] = (_data[t] || []).filter(e => e.id !== entry.id);
UI.modal.close();
_renderTab();
UI.toast.success('Eintrag gelöscht.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
}
});
form.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="health-form"][type="submit"]') || form.querySelector('[type="submit"]');
const fd = UI.formData(form);
await UI.asyncButton(btn, async () => {
const payload = _buildPayload(fd, t);
let saved;
if (isEdit) {
saved = await API.health.update(_appState.activeDog.id, entry.id, payload);
const idx = (_data[t] || []).findIndex(x => x.id === entry.id);
if (idx !== -1) _data[t][idx] = saved;
UI.toast.success('Gespeichert.');
} else {
saved = await API.health.create(_appState.activeDog.id, { ...payload, typ: t });
if (!_data[t]) _data[t] = [];
_data[t].unshift(saved);
UI.toast.success('Eintrag erstellt.');
}
// Gewicht im App-State aktualisieren (für neuen Eintrag UND bei Bearbeitung)
if (t === 'gewicht' && saved.wert) {
_appState.activeDog.gewicht_kg = saved.wert;
_appState.dogs = _appState.dogs.map(d =>
d.id === _appState.activeDog.id
? { ...d, gewicht_kg: saved.wert }
: d
);
}
// Multi-File-Upload
const fileInput = form.querySelector('[name="datei_neu"]');
const files = fileInput ? Array.from(fileInput.files || []) : [];
if (files.length) {
const dogId = _appState.activeDog.id;
if (!saved.media_items) saved.media_items = [];
for (const f of files) {
try {
const toUpload = await API.compressImage(f);
const fd = new FormData();
fd.append('file', toUpload);
const res = await API.health.uploadMedia(dogId, saved.id, fd);
saved.media_items.push({ id: res.id, url: res.url, media_type: res.media_type });
// Rückwärtskompatibilität: erste Datei auch als datei_url sichern
if (!saved.datei_url) {
saved.datei_url = res.url;
saved.datei_typ = res.media_type;
}
} catch {
UI.toast.warning(`Datei "${f.name}" konnte nicht hochgeladen werden.`);
}
}
}
UI.modal.close();
_renderTab();
});
});
}
function _formPlaceholder(typ) {
const ph = {
impfung: 'z.B. Tollwut, DHPP, Leptospirose',
tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung',
gewicht: '',
medikament: 'z.B. Frontline, Milbemax',
allergie: 'z.B. Hühnchen, Gras, Hausstaub',
dokument: 'z.B. Impfpass, Blutbild',
laeufigkeit: 'Läufigkeit',
};
return ph[typ] || '';
}
// Intervall-Auswahl für wiederkehrende Einträge
function _intervallField(entry) {
const v = entry?.intervall_tage;
const opts = [
[null, 'Einmalig'],
[30, 'Monatlich (30 Tage)'],
[60, 'Alle 2 Monate'],
[90, 'Vierteljährlich (90 Tage)'],
[180, 'Halbjährlich'],
[365, 'Jährlich'],
];
return `
<div class="form-group">
<label class="form-label">Wiederholt sich</label>
<select class="form-control" name="intervall_tage">
${opts.map(([days, label]) =>
`<option value="${days ?? ''}" ${(v == days) ? 'selected' : ''}>${label}</option>`
).join('')}
</select>
</div>`;
}
// Wiederverwendbares Praxis-Dropdown für alle Formulare
function _praxisSelectField(entry) {
const aktivePraxen = _praxen.filter(p => p.aktiv);
if (!aktivePraxen.length) return '';
return `
<div class="form-group">
<label class="form-label">Behandelnde Praxis</label>
<select class="form-control" name="tierarzt_id">
<option value=""> optional </option>
${aktivePraxen.map(p => `
<option value="${p.id}" ${entry?.tierarzt_id === p.id ? 'selected' : ''}>
${_esc(p.name)}${p.ort ? ` · ${_esc(p.ort)}` : ''}
</option>`).join('')}
</select>
</div>`;
}
function _extraFormFields(entry, typ) {
switch (typ) {
case 'impfung': return `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Nächste Impfung</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
${_intervallField(entry)}
</div>
${_praxisSelectField(entry)}
<div class="form-group">
<label class="form-label">Chargen-Nr.</label>
<input class="form-control" type="text" name="charge_nr" value="${_esc(entry?.charge_nr || '')}">
</div>
`;
case 'entwurmung': return `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Nächste Behandlung</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
${_intervallField(entry)}
</div>
${_praxisSelectField(entry)}
`;
case 'tierarzt': {
const aktivePraxen = _praxen.filter(p => p.aktiv);
const praxisField = aktivePraxen.length
? `<div class="form-group">
<label class="form-label">Behandelnde Praxis</label>
<select class="form-control" id="health-praxis-select" name="tierarzt_id">
<option value=""> Praxis wählen </option>
${aktivePraxen.map(p => `
<option value="${p.id}"
${entry?.tierarzt_id === p.id ? 'selected' : ''}>
${_esc(p.name)}${p.ort ? ` · ${_esc(p.ort)}` : ''}
</option>`).join('')}
</select>
</div>`
: `<div class="form-group">
<div style="padding:var(--space-3);background:var(--c-bg);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Noch keine Praxis angelegt —
<button type="button" class="btn btn-ghost btn-sm" style="padding:0;font-size:inherit"
data-action="goto-praxen">Praxis im Tab Praxen anlegen</button>
</div>
<label class="form-label" style="margin-top:var(--space-2)">Tierarzt / Praxis (Freitext)</label>
<input class="form-control" name="tierarzt_name"
value="${_esc(entry?.tierarzt_name || '')}" placeholder="Dr. Muster">
</div>`;
return `
${praxisField}
<div class="form-group">
<label class="form-label">Diagnose</label>
<textarea class="form-control" name="diagnose" rows="2">${_esc(entry?.diagnose || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Kosten (€)</label>
<input class="form-control" type="number" step="0.01" min="0" name="kosten"
value="${entry?.kosten ?? ''}">
</div>
<div class="form-group">
<label class="form-label">Nächster Termin (optional)</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
`;
}
case 'gewicht': return `
<div class="form-group">
<label class="form-label">Gewicht (kg) *</label>
<input class="form-control" type="number" step="0.01" min="0" name="wert"
value="${entry?.wert ?? ''}" required>
</div>
`;
case 'medikament': return `
<div class="form-group">
<label class="form-label">Dosierung</label>
<input class="form-control" type="text" name="dosierung"
value="${_esc(entry?.dosierung || '')}" placeholder="z.B. 1 Tablette">
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
<input class="form-control" type="text" name="haeufigkeit"
value="${_esc(entry?.haeufigkeit || '')}" placeholder="z.B. täglich, 2x wöchentlich">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Gabe bis (optional)</label>
<input class="form-control" type="date" name="bis_datum" value="${entry?.bis_datum || ''}">
</div>
${_intervallField(entry)}
</div>
${_praxisSelectField(entry)}
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="aktiv" ${entry?.aktiv !== 0 ? 'checked' : ''}>
Aktuell aktiv
</label>
</div>
`;
case 'allergie': return `
<div class="form-group">
<label class="form-label">Schweregrad</label>
<select class="form-control" name="schweregrad">
<option value="">— unbekannt —</option>
${['leicht', 'mittel', 'schwer'].map(s =>
`<option value="${s}" ${entry?.schweregrad === s ? 'selected' : ''}>${s}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Reaktion / Symptome</label>
<textarea class="form-control" name="reaktion" rows="2">${_esc(entry?.reaktion || '')}</textarea>
</div>
`;
case 'laeufigkeit': {
const prevCycles = (_data['laeufigkeit'] || []).filter(e => e !== entry && e?.datum);
const sorted = [...prevCycles].sort((a, b) => a.datum.localeCompare(b.datum));
const lastCycle = sorted[sorted.length - 1];
// Abstand zur letzten Läufigkeit (in Tagen)
let daysSinceLast = null;
if (lastCycle) {
daysSinceLast = Math.round((new Date() - new Date(lastCycle.datum)) / 86400000);
}
// Durchschnittlicher Zyklus aus ≥2 Einträgen, sonst gemessener Abstand
let avgInterval = 0;
if (sorted.length >= 2) {
const intervals = [];
for (let i = 1; i < sorted.length; i++) {
intervals.push(Math.round((new Date(sorted[i].datum) - new Date(sorted[i-1].datum)) / 86400000));
}
avgInterval = Math.round(intervals.reduce((a, b) => a + b, 0) / intervals.length);
} else if (daysSinceLast !== null) {
avgInterval = daysSinceLast; // erster gemessener Abstand als Vorschlag
}
const defaultInterval = avgInterval || (entry?.intervall_tage) || 180;
const lastInfo = lastCycle ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3)">
Letzte Läufigkeit: <strong>${UI.time.format(lastCycle.datum + 'T00:00:00')}</strong>
— vor <strong>${daysSinceLast} Tagen</strong>
</div>` : '';
return `
${lastInfo}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Dauer (Tage)</label>
<input class="form-control" type="number" min="1" max="60" name="wert"
value="${entry?.wert ?? ''}" placeholder="z.B. 21"
id="laeufi-dauer">
</div>
<div class="form-group">
<label class="form-label">Zyklusabstand (Tage)</label>
<input class="form-control" type="number" min="60" max="400" name="intervall_tage"
value="${entry?.intervall_tage || defaultInterval}"
id="laeufi-interval">
</div>
</div>
<div class="form-group">
<label class="form-label">Nächste erwartet</label>
<input class="form-control" type="date" name="naechstes"
value="${entry?.naechstes || ''}" id="laeufi-naechstes">
</div>
${['breeder', 'admin'].includes(_appState.user?.rolle) ? `
<div class="form-group" id="laeufi-zuechter-fields" style="margin-top:var(--space-4);
padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--space-3)">
Zucht (optional)
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Deckdatum</label>
<input class="form-control" type="date" name="deckdatum"
value="${entry?.deckdatum || ''}" id="laeufi-deckdatum">
</div>
<div class="form-group">
<label class="form-label">Wurftermin (63 Tage nach Deckung)</label>
<input class="form-control" type="date" name="wurftermin"
value="${entry?.wurftermin || ''}" id="laeufi-wurftermin" readonly
style="background:var(--c-surface-2)">
</div>
</div>
</div>` : ''}
<script>
(function() {
const datum = document.querySelector('[name="datum"]');
const interval = document.getElementById('laeufi-interval');
const naechstes = document.getElementById('laeufi-naechstes');
function updateNext() {
const d = datum?.value;
const iv = parseInt(interval?.value) || 0;
if (d && iv) {
const next = new Date(d);
next.setDate(next.getDate() + iv);
naechstes.value = next.toISOString().slice(0, 10);
}
}
datum?.addEventListener('change', updateNext);
interval?.addEventListener('change', updateNext);
if (!naechstes?.value) updateNext();
const deckdatum = document.getElementById('laeufi-deckdatum');
const wurftermin = document.getElementById('laeufi-wurftermin');
deckdatum?.addEventListener('change', e => {
const deckDate = new Date(e.target.value);
if (!isNaN(deckDate)) {
deckDate.setDate(deckDate.getDate() + 63);
wurftermin.value = deckDate.toISOString().split('T')[0];
}
});
})();
</script>
`;
}
default: return '';
}
}
function _buildPayload(fd, typ) {
const p = {
bezeichnung: fd.bezeichnung || null,
datum: fd.datum || null,
notiz: fd.notiz || null,
naechstes: fd.naechstes || null,
tierarzt_name: fd.tierarzt_name || null,
charge_nr: fd.charge_nr || null,
diagnose: fd.diagnose || null,
dosierung: fd.dosierung || null,
haeufigkeit: fd.haeufigkeit || null,
bis_datum: fd.bis_datum || null,
schweregrad: fd.schweregrad || null,
reaktion: fd.reaktion || null,
};
if (fd.wert) {
p.wert = parseFloat(fd.wert.toString().replace(',', '.'));
if (typ === 'gewicht') p.bezeichnung = `${p.wert} kg`;
}
if (typ === 'laeufigkeit') {
p.bezeichnung = p.bezeichnung || 'Läufigkeit';
p.deckdatum = fd.deckdatum || null;
p.wurftermin = fd.wurftermin || null;
}
if (fd.kosten) p.kosten = parseFloat(fd.kosten.toString().replace(',', '.'));
if (fd.tierarzt_id) {
p.tierarzt_id = parseInt(fd.tierarzt_id);
// Praxisname auch als tierarzt_name sichern (bleibt lesbar wenn Praxis inaktiv/gelöscht)
const praxis = _praxen.find(x => x.id === p.tierarzt_id);
if (praxis) p.tierarzt_name = praxis.name;
}
if (typ === 'medikament') {
p.aktiv = 'aktiv' in fd ? 1 : 0;
}
p.intervall_tage = fd.intervall_tage ? parseInt(fd.intervall_tage) : null;
// Gewicht-Einheit
p.einheit = fd.einheit || 'kg';
return p;
}
// ----------------------------------------------------------
// PRAXEN — Liste
// ----------------------------------------------------------
function _renderPraxen() {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-praxis">+ Praxis hinzufügen</button>`;
const aktive = _praxen.filter(p => p.aktiv);
const inaktive = _praxen.filter(p => !p.aktiv);
if (!_praxen.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Praxis eingetragen',
text: 'Trage deine Tierarztpraxis ein für schnellen Zugriff.',
action: addBtn
});
const renderCard = p => {
const isFav = _favoritVet?.id === p.id || p.is_favorite;
const hasRating = p.anz_bewertungen > 0;
const stars = hasRating ? _renderStarsReadonly(p.avg_rating) : '';
const ratingHtml = hasRating
? `<div style="display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-1);font-size:var(--text-sm)">
${stars}
<span style="color:var(--c-text-secondary)">${p.avg_rating.toFixed(1)} (${p.anz_bewertungen} Bew.)</span>
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">Noch keine Bewertungen</div>`;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
<div class="health-card-body">
<div class="health-card-title">
${_esc(p.name)}
${!p.aktiv ? '<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:400"> · Ehemalig</span>' : ''}
</div>
${(p.strasse || p.plz || p.ort) ? `
<div class="health-card-meta">
${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')}
</div>` : ''}
${p.opening_hours ? `
<div class="health-card-meta" style="margin-top:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="font-size:0.9em"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
</div>` : ''}
${ratingHtml}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? `
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> Anrufen
</a>` : ''}
${p.notfall_telefon ? `
<a href="tel:${_esc(p.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm btn-secondary"
data-action="bewerten" data-praxis-id="${p.id}"
title="Bewertung abgeben"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Bewerten
</button>
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
<button class="btn btn-sm btn-secondary"
data-action="edit-praxis" data-praxis-id="${p.id}"
title="Praxis bearbeiten"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>
</div>
</div>
</div>
`};
const favCard = _favoritVet ? `
<div style="margin-bottom:var(--space-4)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
${UI.icon('heart')} Mein Tierarzt
</div>
${renderCard(_favoritVet)}
</div>` : '';
const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id);
return `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
${addBtn}
</div>
${favCard}
<div class="health-list">
${ohneGesetzt.map(renderCard).join('')}
${inaktive.length ? `
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
border-top:1px solid var(--c-border)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3)">Ehemalige Praxen</p>
${inaktive.map(renderCard).join('')}
</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// PRAXEN — Sterne-Hilfs-Funktionen
// ----------------------------------------------------------
/** Rendert 5 Sterne (readonly, filled bis `rating`). */
function _renderStarsReadonly(rating) {
const full = Math.round(rating);
return Array.from({ length: 5 }, (_, i) => {
const filled = i < full;
return `<span aria-hidden="true" style="font-size:1em;color:${filled ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'}">&#9733;</span>`;
}).join('');
}
/** Rendert 5 klickbare Sterne mit data-val. */
function _renderStarsInput(name, current) {
return `<div class="bew-stars" data-name="${name}" role="group" aria-label="Bewertung ${name}"
style="display:flex;gap:2px;cursor:pointer">
${Array.from({ length: 5 }, (_, i) => {
const val = i + 1;
const filled = current >= val;
return `<span class="bew-star" data-val="${val}"
style="font-size:1.6rem;color:${filled ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'};
transition:color .1s">&#9733;</span>`;
}).join('')}
</div>`;
}
// ----------------------------------------------------------
// PRAXEN — Detail-Modal (Bewertungen anzeigen)
// ----------------------------------------------------------
async function _showPraxisDetail(praxis) {
// Erst mit Lade-Spinner öffnen, dann Daten laden
UI.modal.open({
title: _esc(praxis.name),
body: `<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon spin" aria-hidden="true" style="font-size:2rem">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="detail-bewerten-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Jetzt bewerten
</button>`,
});
document.getElementById('detail-bewerten-btn')
?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); });
let data;
try {
data = await API.tieraerzte.bewertungen(praxis.id);
} catch {
UI.modal.open({ title: praxis.name, body: '<p>Bewertungen konnten nicht geladen werden.</p>' });
return;
}
const { avg_rating, anz_bewertungen, verteilung, kommentare } = data;
// Balkendiagramm
const balken = [5, 4, 3, 2, 1].map(s => {
const n = verteilung[String(s)] || 0;
const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0;
return `<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm)">
<span style="min-width:1.2em;text-align:right">${s}</span>
<span aria-hidden="true" style="color:var(--c-warning,#f59e0b);font-size:.9em">&#9733;</span>
<div style="flex:1;height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:var(--c-warning,#f59e0b);border-radius:4px"></div>
</div>
<span style="min-width:2em;color:var(--c-text-secondary)">${n}</span>
</div>`;
}).join('');
const kommentarHtml = kommentare.length
? kommentare.map(k => `
<div style="padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-1)">
${_renderStarsReadonly(k.gesamt)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
${k.created_at ? k.created_at.slice(0, 10) : ''}
</span>
</div>
${k.wartezeit || k.freundlichkeit || k.kompetenz ? `
<div style="display:flex;gap:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">
${k.wartezeit ? `<span>Wartezeit: ${_renderStarsReadonly(k.wartezeit)}</span>` : ''}
${k.freundlichkeit ? `<span>Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}</span>` : ''}
${k.kompetenz ? `<span>Kompetenz: ${_renderStarsReadonly(k.kompetenz)}</span>` : ''}
</div>` : ''}
<p style="margin:0;font-size:var(--text-sm)">${_esc(k.text || '')}</p>
</div>`).join('')
: `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Kommentare.</p>`;
const bewBody = anz_bewertungen === 0
? `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-4) 0">
Noch keine Bewertungen — sei der Erste!
</p>`
: `
<div style="display:flex;align-items:center;gap:var(--space-4);margin-bottom:var(--space-4)">
<div style="text-align:center">
<div style="font-size:3rem;font-weight:700;line-height:1">${avg_rating.toFixed(1)}</div>
<div>${_renderStarsReadonly(avg_rating)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}</div>
</div>
<div style="flex:1">${balken}</div>
</div>
<div>${kommentarHtml}</div>`;
// Modal-Body aktualisieren (ohne Modal neu zu öffnen)
const modalBody = document.querySelector('.modal-body');
if (modalBody) modalBody.innerHTML = bewBody;
}
// ----------------------------------------------------------
// PRAXEN — Bewertungs-Modal
// ----------------------------------------------------------
async function _showBewertungModal(praxis) {
// Ggf. bestehende Bewertung laden
let existing = null;
try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ }
const cur = existing || {};
const body = `
<form id="bew-form">
<div class="form-group">
<label class="form-label" style="font-weight:600">Gesamteindruck *</label>
${_renderStarsInput('gesamt', cur.gesamt || 0)}
<input type="hidden" name="gesamt" id="bew-gesamt" value="${cur.gesamt || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Wartezeit</label>
${_renderStarsInput('wartezeit', cur.wartezeit || 0)}
<input type="hidden" name="wartezeit" id="bew-wartezeit" value="${cur.wartezeit || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Freundlichkeit</label>
${_renderStarsInput('freundlichkeit', cur.freundlichkeit || 0)}
<input type="hidden" name="freundlichkeit" id="bew-freundlichkeit" value="${cur.freundlichkeit || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Kompetenz</label>
${_renderStarsInput('kompetenz', cur.kompetenz || 0)}
<input type="hidden" name="kompetenz" id="bew-kompetenz" value="${cur.kompetenz || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Kommentar <span style="font-weight:400;color:var(--c-text-muted)">(optional, anonym)</span></label>
<textarea class="form-control" name="text" maxlength="500" rows="3"
placeholder="Deine Erfahrungen mit dieser Praxis…">${_esc(cur.text || '')}</textarea>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:right">max. 500 Zeichen</div>
</div>
</form>`;
UI.modal.open({
title: `${_esc(praxis.name)} bewerten`,
body,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="bew-submit-btn" form="bew-form">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
${existing ? 'Bewertung aktualisieren' : 'Bewertung abgeben'}
</button>`,
});
// Sterne-Interaktion
document.querySelectorAll('.bew-stars').forEach(group => {
const name = group.dataset.name;
const hidden = document.getElementById(`bew-${name}`);
const stars = group.querySelectorAll('.bew-star');
const paint = val => {
stars.forEach(s => {
s.style.color = parseInt(s.dataset.val) <= val
? 'var(--c-warning,#f59e0b)' : 'var(--c-border)';
});
};
stars.forEach(s => {
s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val)));
s.addEventListener('mouseleave', () => paint(parseInt(hidden.value)));
s.addEventListener('click', () => {
hidden.value = s.dataset.val;
paint(parseInt(s.dataset.val));
});
});
paint(parseInt(hidden.value));
});
// Submit
document.getElementById('bew-submit-btn').addEventListener('click', async (e) => {
e.preventDefault();
const form = document.getElementById('bew-form');
const gesamt = parseInt(document.getElementById('bew-gesamt').value);
if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; }
const payload = { gesamt };
const wz = parseInt(document.getElementById('bew-wartezeit').value);
const fr = parseInt(document.getElementById('bew-freundlichkeit').value);
const ko = parseInt(document.getElementById('bew-kompetenz').value);
if (wz) payload.wartezeit = wz;
if (fr) payload.freundlichkeit = fr;
if (ko) payload.kompetenz = ko;
const txt = form.querySelector('textarea[name="text"]').value.trim();
if (txt) payload.text = txt;
await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => {
const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload);
// _praxen-Cache aktualisieren
_praxen = _praxen.map(p =>
p.id === praxis.id
? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen }
: p
);
UI.modal.close();
UI.toast.success('Bewertung gespeichert.');
_renderTab();
});
});
}
// ----------------------------------------------------------
// PRAXEN — Formular (Neu / Bearbeiten)
// ----------------------------------------------------------
function _showPraxForm(praxis) {
const isEdit = !!praxis;
UI.modal.open({
title: isEdit ? `${praxis.name} bearbeiten` : 'Praxis hinzufügen',
body: `
<form id="praxis-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name der Praxis *</label>
<input class="form-control" type="text" name="name"
value="${_esc(praxis?.name || '')}" placeholder="Dr. Muster Tierarztpraxis" required>
</div>
<div class="form-group">
<label class="form-label">Straße &amp; Hausnummer</label>
<input class="form-control" type="text" name="strasse"
value="${_esc(praxis?.strasse || '')}" placeholder="Musterstraße 1">
</div>
<div style="display:grid;grid-template-columns:120px 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">PLZ</label>
<input class="form-control" type="text" name="plz" inputmode="numeric"
value="${_esc(praxis?.plz || '')}" placeholder="12345">
</div>
<div class="form-group">
<label class="form-label">Ort</label>
<input class="form-control" type="text" name="ort"
value="${_esc(praxis?.ort || '')}" placeholder="Musterstadt">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Telefon</label>
<input class="form-control" type="tel" name="telefon"
value="${_esc(praxis?.telefon || '')}" placeholder="089 123456">
</div>
<div class="form-group">
<label class="form-label">Notfall-Telefon</label>
<input class="form-control" type="tel" name="notfall_telefon"
value="${_esc(praxis?.notfall_telefon || '')}" placeholder="089 999999">
</div>
</div>
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email"
value="${_esc(praxis?.email || '')}" placeholder="praxis@beispiel.de">
</div>
<div class="form-group">
<label class="form-label">
Öffnungszeiten
<button type="button" id="praxis-osm-lookup" class="btn btn-secondary btn-sm"
style="margin-left:var(--space-2);font-size:var(--text-xs)">
📍 Aus Karte laden
</button>
</label>
<input class="form-control" type="text" name="opening_hours"
id="praxis-opening-hours"
value="${_esc(praxis?.opening_hours || '')}"
placeholder="z.B. MoFr 08:0018:00 · Sa 09:0013:00">
<div id="praxis-osm-results" style="display:none;margin-top:var(--space-2)"></div>
</div>
<div class="form-group">
<label class="form-label">Notizen</label>
<textarea class="form-control" name="notizen" rows="2"
placeholder="Besonderheiten, interne Hinweise…">${_esc(praxis?.notizen || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="ist_notfallpraxis" ${praxis?.ist_notfallpraxis ? 'checked' : ''}>
Notfallpraxis (24h / Wochenende)
</label>
</div>
${isEdit ? `
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="inaktiv" ${!praxis?.aktiv ? 'checked' : ''}>
Als ehemalige Praxis markieren (bei Umzug / Arztwechsel)
</label>
</div>` : ''}
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="praxis-cancel">Abbrechen</button>
<button type="submit" form="praxis-form" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
`,
});
document.getElementById('praxis-cancel')?.addEventListener('click', UI.modal.close);
// OSM-Lookup: Tierarztpraxen in der Nähe suchen und Öffnungszeiten übernehmen
document.getElementById('praxis-osm-lookup')?.addEventListener('click', async btn => {
const lookupBtn = document.getElementById('praxis-osm-lookup');
const resultsEl = document.getElementById('praxis-osm-results');
lookupBtn.disabled = true;
lookupBtn.textContent = 'Suche…';
try {
const pos = await API.getLocation();
const hits = await API.tieraerzte.osmNearby(pos.lat, pos.lon);
if (!hits.length) {
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">Keine Praxen in der Nähe im OSM-Cache gefunden.</p>';
} else {
resultsEl.style.display = 'block';
resultsEl.innerHTML = hits.map(h => `
<div class="health-card" style="margin-bottom:var(--space-2)">
<div style="cursor:pointer;flex:1"
data-osm-id="${_esc(h.osm_id)}"
data-name="${_esc(h.name)}"
data-oh="${_esc(h.opening_hours || '')}"
data-phone="${_esc(h.phone || '')}"
data-action="pick-osm">
<div style="font-weight:600">${_esc(h.name)}</div>
${h.opening_hours_fmt ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(h.opening_hours_fmt)}</div>` : '<div style="font-size:var(--text-sm);color:var(--c-text-muted)">Öffnungszeiten unbekannt</div>'}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${h.distanz_km} km entfernt</div>
</div>
<button class="btn btn-secondary btn-sm" style="flex-shrink:0;align-self:flex-start"
data-action="korrigieren"
data-osm-id="${_esc(h.osm_id)}"
data-poi-name="${_esc(h.name)}"
data-current-oh="${_esc(h.opening_hours || '')}">
✏️
</button>
</div>
`).join('');
resultsEl.querySelectorAll('[data-action="pick-osm"]').forEach(el => {
el.addEventListener('click', () => {
const nameInput = document.querySelector('[name="name"]');
const ohInput = document.getElementById('praxis-opening-hours');
const telInput = document.querySelector('[name="telefon"]');
if (nameInput && !nameInput.value) nameInput.value = el.dataset.name;
if (ohInput) ohInput.value = el.dataset.oh;
if (telInput && !telInput.value) telInput.value = el.dataset.phone;
resultsEl.style.display = 'none';
UI.toast.success('Daten übernommen.');
});
});
resultsEl.querySelectorAll('[data-action="korrigieren"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_showPoiKorrekturModal(btn.dataset.osmId, btn.dataset.poiName, btn.dataset.currentOh);
});
});
}
} catch (err) {
UI.toast.warning('Standort nicht verfügbar oder kein OSM-Cache in der Nähe.');
} finally {
lookupBtn.disabled = false;
lookupBtn.textContent = '📍 Aus Karte laden';
}
});
document.getElementById('praxis-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="praxis-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const payload = {
name: fd.name?.trim(),
strasse: fd.strasse || null,
plz: fd.plz || null,
ort: fd.ort || null,
telefon: fd.telefon || null,
notfall_telefon: fd.notfall_telefon || null,
email: fd.email || null,
notizen: fd.notizen || null,
ist_notfallpraxis: 'ist_notfallpraxis' in fd,
opening_hours: fd.opening_hours || null,
};
if (!payload.name) { UI.toast.warning('Bitte einen Namen eingeben.'); return; }
let saved;
if (isEdit) {
payload.aktiv = !('inaktiv' in fd);
saved = await API.tieraerzte.update(praxis.id, payload);
_praxen = _praxen.map(p => p.id === praxis.id ? saved : p);
UI.toast.success('Praxis gespeichert.');
} else {
saved = await API.tieraerzte.create(payload);
_praxen.push(saved);
UI.toast.success(`${saved.name} hinzugefügt.`);
}
UI.modal.close();
_renderTab();
});
});
}
// ----------------------------------------------------------
// SYMPTOM-CHECK
// ----------------------------------------------------------
function _renderSymptomCheck(content) {
content.innerHTML = `
<div style="padding:var(--space-4)">
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Einschätzung — kein Ersatz für den Tierarzt.
</p>
<div class="form-group">
<label class="form-label" for="symptom-input">Symptome</label>
<textarea id="symptom-input" class="form-control" rows="4"
placeholder="z.B. frisst nicht, trinkt viel, schläft mehr als sonst..."></textarea>
</div>
<button id="symptom-submit-btn" class="btn btn-primary" style="width:100%">
Symptome analysieren <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
</button>
<div id="symptom-result" style="display:none;margin-top:var(--space-5)"></div>
</div>
</div>
`;
content.querySelector('#symptom-submit-btn').addEventListener('click', async function () {
const btn = this;
const textarea = content.querySelector('#symptom-input');
const resultEl = content.querySelector('#symptom-result');
const symptoms = textarea.value.trim();
if (!symptoms) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post(
`/dogs/${_appState.activeDog.id}/health/symptom-check`,
{ symptoms }
);
} catch (err) {
if (err.status === 402) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning)">
<p style="margin:0;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
</p>
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger)">
<p style="margin:0;font-size:var(--text-sm)">
KI-Server nicht erreichbar. Bitte später versuchen.
</p>
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der Symptomanalyse.');
return;
}
resultEl.style.display = '';
return;
}
const DRINGLICHKEIT = {
beobachten: { badgeClass: 'badge-success', icon: 'check-circle', label: 'Beobachten' },
tierarzt_heute:{ badgeClass: 'badge-warning', icon: 'warning', label: 'Heute zum Tierarzt' },
tierarzt: { badgeClass: 'badge-warning', icon: 'warning', label: 'Zum Tierarzt' },
tierarzt_sofort:{ badgeClass: 'badge-danger', icon: 'first-aid-kit', label: 'Sofort zum Tierarzt!' },
notfall: { badgeClass: 'badge-danger', icon: 'first-aid-kit', label: 'Notfall — sofort zum Tierarzt!' },
};
const d = DRINGLICHKEIT[result.dringlichkeit] || { badgeClass: 'badge-primary', icon: 'info', label: _esc(result.dringlichkeit) };
const hinweiseHtml = (result.hinweise || []).length
? `<ul style="margin:var(--space-2) 0 0;padding-left:var(--space-5);font-size:var(--text-sm)">
${result.hinweise.map(h => `<li style="margin-bottom:var(--space-1)">${_esc(h)}</li>`).join('')}
</ul>`
: '';
const zumTierarztHtml = result.zum_tierarzt_wenn
? `<div style="margin-top:var(--space-3);padding:var(--space-3);
background:var(--c-surface-alt,var(--c-surface));
border-radius:var(--radius-md);font-size:var(--text-sm)">
<strong>Zum Tierarzt wenn:</strong> ${_esc(result.zum_tierarzt_wenn)}
</div>`
: '';
resultEl.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="badge ${d.badgeClass}" style="display:inline-flex;align-items:center;gap:var(--space-1);font-size:var(--text-sm);padding:var(--space-1) var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${d.icon}"></use></svg>
${d.label}
</span>
</div>
${result.einschaetzung
? `<p style="font-size:var(--text-sm);line-height:1.6;margin:0">${_esc(result.einschaetzung)}</p>`
: ''}
${hinweiseHtml}
${zumTierarztHtml}
`;
resultEl.style.display = '';
});
});
}
// ----------------------------------------------------------
// TRANSPONDER-BEARBEITUNG
// ----------------------------------------------------------
async function _editTransponder(dog) {
const currentNr = dog?.chip_nr || '';
UI.modal.open({
title: 'Transpondernummer',
body: `
<div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label>
<input id="transponder-input" class="form-control" type="text"
value="${_esc(currentNr)}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="transponder-save-btn">Speichern</button>`,
});
document.getElementById('transponder-save-btn').addEventListener('click', async () => {
const nr = document.getElementById('transponder-input').value.trim() || null;
const btn = document.getElementById('transponder-save-btn');
UI.setLoading(btn, true);
try {
await API.dogs.update(dog.id, { chip_nr: nr });
_appState.activeDog.chip_nr = nr;
UI.modal.close();
const nrEl = _container.querySelector('#health-transponder-nr');
if (nrEl) nrEl.innerHTML = nr
? `<strong>${_esc(nr)}</strong>`
: '<em style="color:var(--c-text-muted)">nicht eingetragen</em>';
} catch (e) {
UI.setLoading(btn, false);
UI.toast('Fehler beim Speichern', 'error');
}
});
}
// ----------------------------------------------------------
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
// ----------------------------------------------------------
async function _loadKiBerichte(dogId, force = false) {
const el = _container.querySelector('#health-ki-berichte');
if (!el) return;
try {
// force=true: Cache-Buster damit SW den neuen Bericht nicht übersieht
const berichte = force
? await API.get(`/dogs/${dogId}/health/ki-berichte?_t=${Date.now()}`)
: await API.health.kiBerichte(dogId);
if (!berichte || berichte.length === 0) return;
const neuester = berichte[0];
const datum = neuester.erstellt_at
? new Date(neuester.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
const preview = neuester.bericht.length > 180
? _esc(neuester.bericht.slice(0, 180)) + '&hellip;'
: _esc(neuester.bericht);
el.innerHTML = `
<div class="health-ki-bericht-banner" style="
background:var(--c-surface-2,#f7f2eb);
border:1px solid var(--c-border,#e2d9ce);
border-radius:var(--radius-md,10px);
padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-3);
cursor:pointer;
">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#star"></use></svg>
<strong style="font-size:var(--text-sm)">KI-Gesundheitsbericht</strong>
${datum ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted);margin-left:auto">${datum}</span>` : ''}
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-muted);line-height:1.5">${preview}</div>
${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''}
</div>`;
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
let idx = 0;
const fmtDate = b => b.erstellt_at
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
function showBericht() {
const b = berichte[idx];
const nav = berichte.length > 1 ? `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<button onclick="window._kiPrev()" style="padding:6px 16px;border-radius:999px;
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
font-size:var(--text-sm);${idx >= berichte.length-1 ? 'opacity:.3;pointer-events:none' : ''}"> Älter</button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${idx+1} / ${berichte.length}</span>
<button onclick="window._kiNext()" style="padding:6px 16px;border-radius:999px;
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
font-size:var(--text-sm);${idx <= 0 ? 'opacity:.3;pointer-events:none' : ''}">Neuer </button>
</div>` : '';
UI.modal.open({
title: `${UI.icon('star')} KI-Gesundheitsberichte`,
body: `${nav}
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-bottom:8px">${fmtDate(b)}</div>
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>`,
});
}
window._kiPrev = () => { if (idx < berichte.length - 1) { idx++; showBericht(); } };
window._kiNext = () => { if (idx > 0) { idx--; showBericht(); } };
showBericht();
});
} catch (_) {
// Silently ignore — Berichte sind optional
}
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
// TERMINVORSCHLÄGE
// ----------------------------------------------------------
async function _loadTerminvorschlaege(dogId) {
const el = _container.querySelector('#health-terminvorschlaege');
if (!el) return;
try {
const vorschlaege = await API.health.terminvorschlaege(dogId);
if (!vorschlaege || !vorschlaege.length) return;
const _fmtDatum = iso => new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric'
});
el.innerHTML = `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Terminvorschläge
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${vorschlaege.map(v => {
const badge = v.ueberfaellig
? `<span style="font-size:var(--text-xs);color:var(--c-danger);font-weight:600">Überfällig seit ${_fmtDatum(v.naechstes)}</span>`
: `<span style="font-size:var(--text-xs);color:var(--c-warning);font-weight:600">Fällig am ${_fmtDatum(v.naechstes)}</span>`;
return `
<div class="health-card" style="flex-direction:row;align-items:center;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.bezeichnung)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(v.label)}${v.praxis_name ? ' · ' + _esc(v.praxis_name) : ''}</div>
${badge}
</div>
<div style="text-align:right;flex-shrink:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_fmtDatum(v.datum_vorschlag)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${v.uhrzeit_vorschlag} Uhr</div>
<button class="btn btn-primary btn-sm" style="margin-top:var(--space-1)"
data-action="termin-anlegen"
data-v='${_esc(JSON.stringify(v))}'>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-plus"></use></svg> In Kalender
</button>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
el.querySelectorAll('[data-action="termin-anlegen"]').forEach(btn => {
btn.addEventListener('click', async () => {
let v;
try { v = JSON.parse(btn.dataset.v); } catch { return; }
await _terminAnlegen(v, btn);
});
});
} catch { /* still show health page if this fails */ }
}
async function _terminAnlegen(v, btn) {
const titel = v.beim_tierarzt
? `${v.label}: ${v.bezeichnung} (Tierarzt)`
: `${v.label}: ${v.bezeichnung}`;
const beschreibung = v.praxis_name
? `Praxis: ${v.praxis_name}`
: v.ueberfaellig
? `Überfällig seit ${v.naechstes}`
: `Fällig am ${v.naechstes}`;
UI.modal.open({
title: `${UI.icon('calendar-plus')} Termin in Kalender eintragen`,
body: `
<form id="termin-form">
<div class="form-group">
<label class="form-label">Bezeichnung</label>
<input class="form-control" type="text" name="titel" value="${_esc(titel)}" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum</label>
<input class="form-control" type="date" name="datum" value="${_esc(v.datum_vorschlag)}" required>
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input class="form-control" type="time" name="uhrzeit" value="${_esc(v.uhrzeit_vorschlag)}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input class="form-control" type="text" name="beschreibung" value="${_esc(beschreibung)}">
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="termin-cancel">Abbrechen</button>
<button type="submit" form="termin-form" class="btn btn-primary flex-1">Speichern</button>
`,
});
document.getElementById('termin-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('termin-form')?.addEventListener('submit', async e => {
e.preventDefault();
const saveBtn = document.querySelector('[form="termin-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(saveBtn, async () => {
await API.events.create({
titel: fd.titel,
datum: fd.datum,
uhrzeit: fd.uhrzeit || null,
beschreibung: fd.beschreibung || null,
typ: v.beim_tierarzt ? 'tierarzt' : 'sonstiges',
lat: v.praxis_lat ?? null,
lon: v.praxis_lon ?? null,
ort_name: v.praxis_name ?? null,
});
UI.modal.close();
UI.toast.success('Termin gespeichert — erscheint in deinem Kalender.');
});
});
}
// ----------------------------------------------------------
// MEIN TIERARZT — Kachel
// ----------------------------------------------------------
async function _loadMeinTierarzt() {
const el = _container.querySelector('#health-mein-tierarzt');
if (!el) return;
_renderMeinTierarztKachel(el);
}
function _renderMeinTierarztKachel(el) {
if (!el) return;
const vet = _favoritVet;
const adresse = vet
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
: '';
el.innerHTML = `
<div style="margin:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Mein Tierarzt
</div>
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.6rem;flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
${vet ? `
<div class="health-card-title">${_esc(vet.name)}</div>
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
${vet.telefon ? `
<div style="margin-top:var(--space-2)">
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
</a>
</div>` : ''}
${vet.notfall_telefon ? `
<div style="margin-top:var(--space-1)">
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
</a>
</div>` : ''}
` : `
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch kein Tierarzt als Favorit gespeichert.
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
id="health-suche-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
</button>
`}
</div>
${vet ? `
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
</button>
` : ''}
</div>
</div>
`;
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
App.navigate('map', { filter: 'tierarzt' });
});
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await UI.asyncButton(btn, async () => {
await API.tieraerzte.toggleFavorite(_favoritVet.id);
_favoritVet = null;
const elAgain = _container.querySelector('#health-mein-tierarzt');
if (elAgain) _renderMeinTierarztKachel(elAgain);
UI.toast.success('Tierarzt-Favorit entfernt.');
});
});
}
// ----------------------------------------------------------
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
// ----------------------------------------------------------
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
function _renderBefundeSection() {
const dog = _appState.activeDog;
const docs = _healthDocs;
const DOC_ICONS = {
blutbild: 'drop',
roentgen: 'file-text',
rezept: 'note',
impfausweis:'certificate',
sonstiges: 'file-text',
};
const DOC_LABELS = {
blutbild: 'Blutbild',
roentgen: 'Röntgen',
rezept: 'Rezept',
impfausweis:'Impfausweis',
sonstiges: 'Sonstiges',
};
const uploadBtn = `
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
</button>`;
const items = docs.length
? docs.map(doc => {
const icon = DOC_ICONS[doc.typ] || 'file-text';
const label = DOC_LABELS[doc.typ] || doc.typ;
const isImg = !['pdf'].includes(doc.file_type);
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
return `
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
<div class="health-card-title">${_esc(doc.titel)}</div>
<div class="health-card-meta">
${_esc(label)}${datum ? ' · ' + datum : ''}
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
</div>
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
${isImg
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
</a>
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
data-action="delete-hdoc" data-doc-id="${doc.id}"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
</div>`;
}).join('')
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
Noch keine Befunde hochgeladen.
</p>`;
return `
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde &amp; Dokumente
</div>
${uploadBtn}
</div>
<div class="health-list" id="health-docs-list">${items}</div>
</div>
`;
}
function _bindBefundeEvents(content) {
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
_showBefundUploadModal();
});
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const docId = parseInt(btn.dataset.docId);
const ok = window.confirm('Befund wirklich löschen?');
if (!ok) return;
await UI.asyncButton(btn, async () => {
await API.healthDocs.delete(docId);
_healthDocs = _healthDocs.filter(d => d.id !== docId);
_renderTab();
UI.toast.success('Befund gelöscht.');
});
});
});
}
function _showBefundUploadModal() {
const aktivePraxen = _praxen.filter(p => p.aktiv);
const dog = _appState.activeDog;
UI.modal.open({
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
body: `
<form id="befund-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Art des Dokuments *</label>
<select class="form-control" name="typ" required>
<option value=""> bitte wählen </option>
<option value="blutbild">Blutbild</option>
<option value="roentgen">Röntgen</option>
<option value="rezept">Rezept</option>
<option value="impfausweis">Impfausweis</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel" required
placeholder="z.B. Blutbild März 2026">
</div>
<div class="form-group">
<label class="form-label">Untersuchungsdatum</label>
<input class="form-control" type="date" name="datum"
value="${new Date().toISOString().slice(0,10)}">
</div>
${aktivePraxen.length ? `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<select class="form-control" name="vet_id">
<option value=""> optional </option>
${aktivePraxen.map(p =>
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
).join('')}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Zusätzliche Infos (optional)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Datei * (PDF, JPG, PNG, WebP — max. 10 MB)</label>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
<input type="file" name="file" id="befund-file-input"
accept=".pdf,image/*"
required
style="position:absolute;opacity:0;width:1px;height:1px">
</label>
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
color:var(--c-text-secondary)"></div>
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
`,
});
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('befund-file-input')?.addEventListener('change', function () {
const preview = document.getElementById('befund-file-preview');
if (this.files?.length) {
const f = this.files[0];
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
} else {
preview.textContent = '';
}
});
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.querySelector('[form="befund-form"][type="submit"]');
const form = e.target;
const fd = UI.formData(form);
const fileInput = form.querySelector('[name="file"]');
const file = fileInput?.files?.[0];
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
if (file.size > 10 * 1024 * 1024) {
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
return;
}
await UI.asyncButton(btn, async () => {
// Client-Side-Kompression nur wenn Bild (PDFs etc. unverändert durchgereicht)
const toUpload = await API.compressImage(file);
const formData = new FormData();
formData.append('dog_id', String(dog.id));
formData.append('typ', fd.typ);
formData.append('titel', fd.titel);
formData.append('beschreibung', fd.beschreibung || '');
formData.append('datum', fd.datum || '');
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
formData.append('file', toUpload);
try {
const doc = await API.healthDocs.upload(formData);
_healthDocs.unshift(doc);
UI.modal.close();
_renderTab();
UI.toast.success('Befund hochgeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
UI.setLoading(btn, true);
try {
const res = await API.health.kiZusammenfassung(_appState.activeDog.id);
const zusammenfassung = res.zusammenfassung ?? res;
if (res.save_error) UI.toast.warning(`Speichern fehlgeschlagen: ${res.save_error}`);
else if (res.saved_count !== undefined) UI.toast.info(`${res.saved_count} Bericht(e) in DB`, { duration: 8000 });
UI.modal.open({
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
});
// Berichte-Liste nach Generierung frisch laden (Cache-Buster)
_loadKiBerichte(_appState.activeDog.id, true);
} catch (err) {
if (err.status === 503) {
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
} else if (err.status === 402) {
UI.toast.warning('Diese Funktion ist Teil von Ban Yaro Premium.');
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Zusammenfassung.');
}
} finally {
UI.setLoading(btn, false);
}
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _showPoiKorrekturModal(osmId, poiName, currentOh) {
UI.modal.open({
title: 'Öffnungszeiten korrigieren',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Korrektur für <strong>${_esc(poiName)}</strong>.<br>
Dein Vorschlag wird von einem Moderator geprüft und dann für alle übernommen.
</p>
<form id="poi-korrektur-form">
<div class="form-group">
<label class="form-label">Aktuelle Angabe</label>
<input class="form-control" type="text" value="${_esc(currentOh)}" disabled
style="color:var(--c-text-muted)">
</div>
<div class="form-group">
<label class="form-label">Korrekte Öffnungszeiten *</label>
<input class="form-control" type="text" name="new_value" required
placeholder="z.B. MoFr 08:0018:00 · Sa 09:0013:00"
value="${_esc(currentOh)}">
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="poi-kor-cancel">Abbrechen</button>
<button type="submit" form="poi-korrektur-form" class="btn btn-primary flex-1">Einreichen</button>
`,
});
document.getElementById('poi-kor-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('poi-korrektur-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="poi-korrektur-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.post(`/osm/pois/${encodeURIComponent(osmId)}/edit`, {
poi_name: poiName,
field: 'opening_hours',
new_value: fd.new_value.trim(),
});
UI.modal.close();
UI.toast.success('Danke! Dein Vorschlag wird geprüft.');
});
});
}
function _fmtOeffnungszeiten(raw) {
if (!raw) return '';
if (raw.trim().toLowerCase() === '24/7') return '24/7 geöffnet';
return raw.split(';').map(s => s.trim()).filter(Boolean).join(' · ');
}
// ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
// Vorhandenes Modal entfernen falls noch offen
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">${_esc(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;
// Vorhandene Notiz laden
try {
const existing = await API.notes.get(parentType, 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, parentId, payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ----------------------------------------------------------
// KI-TIERARZTFRAGEN
// ----------------------------------------------------------
function _showKiTierarzt() {
const dog = _appState.activeDog;
const dogName = dog?.name || '';
const rasse = dog?.rasse || '';
const placeholder = dogName
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung —
kein Ersatz für einen echten Tierarzt.
</p>
<div class="form-group">
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
placeholder="${_esc(placeholder)}"></textarea>
</div>
<div id="ki-tierarzt-result" style="display:none"></div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fff3cd;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#856404;
border:1px solid #ffc107">
<strong>&#9888;&#65039; Hinweis:</strong> Dies ist keine medizinische Diagnose.
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
});
document.getElementById('ki-tierarzt-submit-btn')
.addEventListener('click', async function () {
const btn = this;
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
const resultEl = document.getElementById('ki-tierarzt-result');
if (!symptom) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post('/ki/tierarzt', {
symptom,
dog_id: dog?.id || null,
dog_name: dogName || null,
rasse: rasse || null,
});
} catch (err) {
if (err.status === 429) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-warning);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-danger);
font-size:var(--text-sm)">
KI momentan nicht verfügbar. Bitte später versuchen.
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
return;
}
resultEl.style.display = '';
return;
}
const antwortHtml = _esc(result.antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
.replace(/\n/g, '<br>');
const restHtml = result.limit - result.anfragen_heute > 0
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
</p>`
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Tageslimit erreicht. Morgen wieder verfügbar.
</p>`;
resultEl.innerHTML = `
<div style="margin-top:var(--space-4);padding:var(--space-4);
background:var(--c-surface);border-radius:var(--radius-md);
border:1px solid var(--c-border)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
Einschätzung
</div>
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
${restHtml}
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fee2e2;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#991b1b;
border:1px solid #fca5a5">
<strong>&#9888;&#65039; Dies ist keine medizinische Diagnose.</strong>
Bei ernsthaften Symptomen sofort zum Tierarzt.
</div>`;
resultEl.style.display = '';
// Submit-Button ausblenden wenn Limit erschöpft
if (result.anfragen_heute >= result.limit) {
btn.disabled = true;
btn.textContent = 'Limit erreicht';
}
});
});
}
// ==============================================================
// BEVORSTEHENDE ERINNERUNGEN (Banner oben in der Health-Seite)
// ==============================================================
async function _loadRemindersBanner() {
const dog = _appState?.activeDog;
if (!dog) return;
const wrap = _container?.querySelector('#health-reminders-banner');
if (!wrap) return;
let items;
try { items = await API.health.reminders(dog.id); }
catch { return; }
if (!items.length) { wrap.style.display = 'none'; return; }
const TYPE_LABEL = { impfung: 'Impfung', entwurmung: 'Entwurmung', medikament: 'Medikament' };
const fmt = d => { try { const p = d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
wrap.style.display = '';
wrap.innerHTML = items.slice(0, 3).map(r => {
const overdue = r.ueberfaellig;
const color = overdue ? 'var(--c-danger,#ef4444)' : r.delta_tage <= 3 ? '#f59e0b' : 'var(--c-primary)';
const bg = overdue ? 'rgba(239,68,68,0.08)' : r.delta_tage <= 3 ? 'rgba(245,158,11,0.08)' : 'var(--c-primary-subtle)';
const label = overdue ? `Überfällig seit ${Math.abs(r.delta_tage)} Tag${Math.abs(r.delta_tage)!==1?'en':''}` :
r.delta_tage === 0 ? 'Heute fällig' :
`in ${r.delta_tage} Tag${r.delta_tage!==1?'en':''}`;
return `
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);
background:${bg};border-radius:var(--radius-md);border-left:3px solid ${color}">
<svg class="ph-icon" style="width:16px;height:16px;color:${color};flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#bell-ringing"></use>
</svg>
<div style="flex:1;min-width:0">
<span style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${_esc(r.bezeichnung)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:var(--space-1)">${TYPE_LABEL[r.typ] || r.typ}</span>
</div>
<span style="font-size:var(--text-xs);font-weight:600;color:${color};white-space:nowrap">${label}</span>
</div>`;
}).join('');
}
// ==============================================================
// TAB: VERSICHERUNG
// ==============================================================
async function _renderVersicherung(content) {
const dog = _appState?.activeDog;
if (!dog) return;
content.innerHTML = `<div style="padding:var(--space-4) 0">
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner-gap"></use></svg>
</div></div>`;
let policies;
try { policies = await API.health.insuranceList(dog.id); }
catch { content.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Fehler beim Laden.</p>`; return; }
const _fmtDate = d => { if (!d) return ''; try { const p=d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
const _fmtEur = v => v ? `${v.toFixed(2).replace('.',',')} €/Jahr` : '';
const cardsHtml = policies.length ? policies.map(p => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)" data-ins-id="${p.id}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-2)">
<div>
<div style="font-weight:700;font-size:var(--text-base)">${_esc(p.anbieter)}</div>
${p.police_nr ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">Police: ${_esc(p.police_nr)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1)">
<button class="btn btn-ghost btn-sm ins-edit-btn" data-id="${p.id}" style="padding:4px 8px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
</button>
<button class="btn btn-ghost btn-sm ins-del-btn" data-id="${p.id}" style="padding:4px 8px;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3);font-size:var(--text-sm)">
<div><span style="color:var(--c-text-secondary)">Jahresbeitrag</span><br><strong>${_fmtEur(p.jahresbeitrag)}</strong></div>
<div><span style="color:var(--c-text-secondary)">Läuft ab</span><br><strong>${_fmtDate(p.ablaufdatum)}</strong></div>
${p.kontakt ? `<div style="grid-column:1/-1"><span style="color:var(--c-text-secondary)">Kontakt</span><br>${_esc(p.kontakt)}</div>` : ''}
${p.notizen ? `<div style="grid-column:1/-1"><span style="color:var(--c-text-secondary)">Notizen</span><br>${_esc(p.notizen)}</div>` : ''}
</div>
</div>`).join('') : `
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:2.5rem;height:2.5rem;margin-bottom:var(--space-3);display:block;margin-inline:auto" aria-hidden="true"><use href="/icons/phosphor.svg#shield-check"></use></svg>
<div style="font-size:var(--text-sm)">Noch keine Versicherung eingetragen.</div>
</div>`;
content.innerHTML = `<div style="padding:var(--space-4) 0">
${cardsHtml}
<button class="btn btn-primary" id="ins-add-btn" style="width:100%;margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
Versicherung eintragen
</button>
</div>`;
content.querySelector('#ins-add-btn')?.addEventListener('click', () => _openInsuranceForm(dog, null, () => _renderVersicherung(content)));
content.querySelectorAll('.ins-edit-btn').forEach(btn => {
const pol = policies.find(p => p.id === parseInt(btn.dataset.id));
btn.addEventListener('click', () => _openInsuranceForm(dog, pol, () => _renderVersicherung(content)));
});
content.querySelectorAll('.ins-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Versicherung löschen?')) return;
await API.health.insuranceDelete(dog.id, parseInt(btn.dataset.id));
_renderVersicherung(content);
});
});
}
function _openInsuranceForm(dog, existing, onSave) {
const id = `ins-form-${Date.now()}`;
const body = `<form id="${id}">
<div class="by-form-group"><label class="by-label">Anbieter *</label>
<input type="text" name="anbieter" class="form-control by-input" value="${_esc(existing?.anbieter||'')}" required placeholder="z. B. HUK-Coburg">
</div>
<div class="by-form-group"><label class="by-label">Police-Nr.</label>
<input type="text" name="police_nr" class="form-control by-input" value="${_esc(existing?.police_nr||'')}" placeholder="optional">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="by-form-group"><label class="by-label">Jahresbeitrag (€)</label>
<input type="number" name="jahresbeitrag" class="form-control by-input" value="${existing?.jahresbeitrag||''}" min="0" step="0.01" placeholder="z. B. 149.00">
</div>
<div class="by-form-group"><label class="by-label">Ablaufdatum</label>
<input type="date" name="ablaufdatum" class="form-control by-input" value="${existing?.ablaufdatum||''}">
</div>
</div>
<div class="by-form-group"><label class="by-label">Kontakt / Telefon</label>
<input type="text" name="kontakt" class="form-control by-input" value="${_esc(existing?.kontakt||'')}" placeholder="optional">
</div>
<div class="by-form-group"><label class="by-label">Notizen</label>
<textarea name="notizen" class="form-control by-input" rows="2">${_esc(existing?.notizen||'')}</textarea>
</div>
</form>`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="ins-save-btn" form="${id}">Speichern</button>`;
UI.modal.open({ title: existing ? 'Versicherung bearbeiten' : 'Versicherung eintragen', body, footer });
setTimeout(() => {
document.getElementById('ins-save-btn')?.addEventListener('click', async ev => {
ev.preventDefault();
const form = document.getElementById(id);
if (!form) return;
const fd = new FormData(form);
const data = {
anbieter: (fd.get('anbieter')||'').trim(),
police_nr: fd.get('police_nr')||null,
jahresbeitrag: fd.get('jahresbeitrag') ? parseFloat(fd.get('jahresbeitrag')) : null,
ablaufdatum: fd.get('ablaufdatum')||null,
kontakt: fd.get('kontakt')||null,
notizen: fd.get('notizen')||null,
};
if (!data.anbieter) { UI.toast.warning('Bitte Anbieter angeben.'); return; }
await UI.asyncButton(document.getElementById('ins-save-btn'), async () => {
try {
if (existing) await API.health.insuranceUpdate(dog.id, existing.id, data);
else await API.health.insuranceCreate(dog.id, data);
UI.modal.close();
UI.toast.success('Gespeichert.');
onSave();
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}, 50);
}
// ==============================================================
// TAB: VERHALTEN
// ==============================================================
const _KAT_LABELS = {
angst: 'Angst / Panik', aggression: 'Aggression', ueberreaktion: 'Überreaktion',
ressource: 'Ressourcenverteidigung', separation: 'Trennungsangst',
leine: 'Leinenprobleme', sozial: 'Sozialkompetenz', sonstiges: 'Sonstiges',
};
const _KAT_COLORS = {
angst: '#3b82f6', aggression: '#ef4444', ueberreaktion: '#f59e0b',
ressource: '#8b5cf6', separation: '#ec4899', leine: '#06b6d4',
sozial: '#22c55e', sonstiges: '#6b7280',
};
const _TRIGGER_LABELS = {
fremde_hunde: 'Fremde Hunde', fremde_menschen: 'Fremde Menschen', kinder: 'Kinder',
laerm_feuerwerk: 'Feuerwerk', laerm_gewitter: 'Gewitter', auto_fahrrad: 'Autos/Fahrräder',
tierarzt: 'Tierarztbesuch', allein_zuhause: 'Allein zuhause',
andere_tiere: 'Andere Tiere', besucher_zuhause: 'Besucher', sonstiges: 'Sonstiges',
};
async function _renderVerhalten(content) {
const dog = _appState?.activeDog;
if (!dog) return;
content.innerHTML = `<div style="padding:var(--space-4) 0">
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner-gap"></use></svg>
</div></div>`;
let resp;
try { resp = await API.health.behaviorList(dog.id); }
catch { content.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Fehler beim Laden.</p>`; return; }
const entries = resp.entries || [];
const fmtDate = d => { try { const p=d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
const listHtml = entries.length ? entries.map(e => {
const color = _KAT_COLORS[e.kategorie] || '#6b7280';
const katLabel = _KAT_LABELS[e.kategorie] || e.kategorie;
const trigLabel = _TRIGGER_LABELS[e.trigger] || e.trigger || '';
const dots = Array.from({length: 5}, (_,i) =>
`<div style="width:8px;height:8px;border-radius:50%;background:${i < e.intensitaet ? color : 'var(--c-border)'}"></div>`
).join('');
return `
<div class="card" style="padding:var(--space-3);margin-bottom:var(--space-2);display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="width:3px;border-radius:2px;background:${color};align-self:stretch;flex-shrink:0"></div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-weight:700;font-size:var(--text-sm);color:${color}">${_esc(katLabel)}</span>
${trigLabel ? `<span style="font-size:var(--text-xs);background:var(--c-surface-2);padding:1px 6px;border-radius:100px;color:var(--c-text-secondary)">${_esc(trigLabel)}</span>` : ''}
<span style="font-size:var(--text-xs);color:var(--c-text-muted);margin-left:auto">${fmtDate(e.datum)}${e.uhrzeit ? ' ' + e.uhrzeit : ''}</span>
</div>
<div style="display:flex;gap:3px;margin-top:4px">${dots}</div>
${e.notiz ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">${_esc(e.notiz)}</div>` : ''}
</div>
<button class="btn btn-ghost btn-sm beh-del-btn" data-id="${e.id}" style="padding:4px 6px;color:var(--c-danger);flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`;
}).join('') : `
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:2.5rem;height:2.5rem;margin-bottom:var(--space-3);display:block;margin-inline:auto" aria-hidden="true"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="font-size:var(--text-sm)">Noch keine Einträge. Protokolliere auffälliges Verhalten um Muster zu erkennen.</div>
</div>`;
content.innerHTML = `<div style="padding:var(--space-4) 0">
${listHtml}
<button class="btn btn-primary" id="beh-add-btn" style="width:100%;margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
Verhalten erfassen
</button>
</div>`;
content.querySelector('#beh-add-btn')?.addEventListener('click', () => _openBehaviorForm(dog, () => _renderVerhalten(content)));
content.querySelectorAll('.beh-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Eintrag löschen?')) return;
await API.health.behaviorDelete(dog.id, parseInt(btn.dataset.id));
_renderVerhalten(content);
});
});
}
function _openBehaviorForm(dog, onSave) {
const id = `beh-form-${Date.now()}`;
const today = new Date().toISOString().slice(0, 10);
const nowTime = (() => { const d=new Date(); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; })();
const body = `<form id="${id}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="by-form-group"><label class="by-label">Datum</label>
<input type="date" name="datum" class="form-control by-input" value="${today}" required>
</div>
<div class="by-form-group"><label class="by-label">Uhrzeit</label>
<input type="time" name="uhrzeit" class="form-control by-input" value="${nowTime}">
</div>
</div>
<div class="by-form-group"><label class="by-label">Kategorie *</label>
<select name="kategorie" class="form-control by-select" required>
<option value=""> wählen </option>
${Object.entries(_KAT_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
</select>
</div>
<div class="by-form-group"><label class="by-label">Intensität (1 = gering, 5 = stark)</label>
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => `<button type="button" class="beh-int-btn" data-val="${n}"
style="flex:1;padding:10px;border-radius:8px;border:1.5px solid var(--c-border);
background:${n<=3?'var(--c-primary)':'var(--c-bg-card)'};
color:${n<=3?'#fff':'var(--c-text-secondary)'};font-weight:700;cursor:pointer">${n}</button>`).join('')}
</div>
<input type="hidden" name="intensitaet" value="3">
</div>
<div class="by-form-group"><label class="by-label">Auslöser</label>
<select name="trigger" class="form-control by-select">
<option value=""> unbekannt </option>
${Object.entries(_TRIGGER_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
</select>
</div>
<div class="by-form-group"><label class="by-label">Notiz</label>
<textarea name="notiz" class="form-control by-input" rows="2" placeholder="Was ist passiert?"></textarea>
</div>
</form>`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="beh-save-btn" form="${id}">Speichern</button>`;
UI.modal.open({ title: 'Verhalten erfassen', body, footer });
setTimeout(() => {
document.querySelectorAll('.beh-int-btn').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.val);
document.querySelectorAll('.beh-int-btn').forEach((b,i) => {
b.style.background = i < val ? 'var(--c-primary)' : 'var(--c-bg-card)';
b.style.color = i < val ? '#fff' : 'var(--c-text-secondary)';
});
const hi = document.querySelector('[name="intensitaet"]');
if (hi) hi.value = val;
});
});
document.getElementById('beh-save-btn')?.addEventListener('click', async ev => {
ev.preventDefault();
const form = document.getElementById(id);
if (!form) return;
const fd = new FormData(form);
const data = {
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit')||null,
kategorie: fd.get('kategorie'),
intensitaet: parseInt(fd.get('intensitaet')||'3'),
trigger: fd.get('trigger')||null,
notiz: (fd.get('notiz')||'').trim()||null,
};
if (!data.kategorie) { UI.toast.warning('Bitte Kategorie wählen.'); return; }
await UI.asyncButton(document.getElementById('beh-save-btn'), async () => {
try {
await API.health.behaviorCreate(dog.id, data);
UI.modal.close();
UI.toast.success('Eintrag gespeichert.');
onSave();
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}, 50);
}
return { init, refresh, openNew, onDogChange };
})();