banyaro/backend/static/js/pages/health.js
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

1964 lines
88 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';
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: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></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;
if (_appState.dogs.length > 1) {
_renderDogPicker();
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;
}
if (_appState.dogs.length > 1) {
_renderDogPicker();
} else {
await _renderHealth();
}
}
// ----------------------------------------------------------
// HUNDE-PICKER
// ----------------------------------------------------------
function _renderDogPicker() {
const activeDogId = _appState.activeDog?.id;
const cards = _appState.dogs.map(dog => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
<div class="diary-picker-av">${av}</div>
<div class="diary-picker-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${_esc(dog.rasse)}</div>` : ''}
</div>`;
}).join('');
_container.innerHTML = `
<div class="diary-picker-wrap">
<p class="diary-picker-hint">Wessen Gesundheitsakte?</p>
<div class="diary-picker-grid">${cards}</div>
</div>`;
_container.querySelectorAll('.diary-picker-card').forEach(el => {
el.addEventListener('click', async () => {
const id = parseInt(el.dataset.dogId);
if (id === _appState.activeDog?.id) {
// Bereits aktiver Hund → direkt Health laden
_data = {};
await _renderHealth();
} else {
App.setActiveDog(id);
// onDogChange() → _renderHealth() via _notifyDogChange()
}
});
});
}
// ----------------------------------------------------------
// 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 = `
<div class="by-toolbar health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
</div>
${transponderHtml}
<div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
`;
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
await _loadAll();
_renderErinnerungen();
_renderTab();
}
// ----------------------------------------------------------
// 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#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></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'] = [];
}
}
// ----------------------------------------------------------
// 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 'symptomcheck': _renderSymptomCheck(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 ? '▲' : delta < 0 ? '▼' : '→';
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>`;
}
// ----------------------------------------------------------
// 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
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) _showPraxForm(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));
}
// ----------------------------------------------------------
// 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.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>` : '';
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}`]);
}
} 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.');
if (t === 'gewicht' && saved.wert) {
_appState.activeDog.gewicht_kg = saved.wert;
}
}
// 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 fd = new FormData();
fd.append('file', f);
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>
<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();
})();
</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';
}
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 => `
<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">${p.ist_notfallpraxis ? '🚨' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#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>` : ''}
<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()">
📞 Anrufen
</a>` : ''}
${p.notfall_telefon ? `
<a href="tel:${_esc(p.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
🚨 Notfall
</a>` : ''}
</div>
</div>
</div>
`;
return `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
${addBtn}
</div>
<div class="health-list">
${aktive.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 — 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">Notizen</label>
<textarea class="form-control" name="notizen" rows="2"
placeholder="Öffnungszeiten, Besonderheiten…">${_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);
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,
};
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', label: '🟢 Beobachten' },
tierarzt: { badgeClass: 'badge-warning', label: '🟡 Zum Tierarzt' },
notfall: { badgeClass: 'badge-danger', label: '🔴 Notfall — sofort zum Tierarzt!' },
};
const d = DRINGLICHKEIT[result.dringlichkeit] || { badgeClass: 'badge-primary', 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="font-size:var(--text-sm);padding:var(--space-1) var(--space-3)">
${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-ZUSAMMENFASSUNG
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
UI.setLoading(btn, true);
try {
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
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>`,
});
} 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;');
}
// ----------------------------------------------------------
// 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);
}
});
}
return { init, refresh, openNew, onDogChange };
})();