banyaro/backend/static/js/pages/health.js

1560 lines
68 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) {
_container = container;
_appState = appState;
await _render();
}
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() {
_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>
<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);
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('');
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
}
}
// ----------------------------------------------------------
// 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);
}
// ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status
// ----------------------------------------------------------
function _renderImpfungen(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: 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>` : ''}
</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>` : ''}
</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 chart = sorted.length >= 2 ? _weightChart(sorted) : '';
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>` : ''}
</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">${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>
`;
}
// ----------------------------------------------------------
// 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>` : ''}
</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>` : ''}
</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>` : ''}
</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 => {
const isPdf = e.datei_typ === 'pdf';
const hasFile = !!e.datei_url;
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
${hasFile && !isPdf
? `<img src="${e.datei_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')}</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
${hasFile
? `<a href="${e.datei_url}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="margin-top:var(--space-2);display:inline-flex"
onclick="event.stopPropagation()">
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
</a>`
: `<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));
});
// 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);
});
});
// 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);
const body = `
<div class="health-detail">
${fields}
${entry.datei_url
? (entry.datei_typ === 'pdf'
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen</a>`
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''}
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)">
<button class="btn btn-secondary flex-1" id="health-detail-edit">Bearbeiten</button>
<button class="btn btn-danger flex-1" id="health-detail-delete">Löschen</button>
</div>
`;
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);
});
document.getElementById('health-detail-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Eintrag löschen?',
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
confirmText: 'Löschen',
danger: true,
});
if (ok) {
try {
await API.health.delete(_appState.activeDog.id, entry.id);
_data[entry.typ] = (_data[entry.typ] || []).filter(e => e.id !== entry.id);
UI.modal.close();
_renderTab();
UI.toast.success('Eintrag gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
}
});
}
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>
`;
const uploadField = t === 'dokument' ? `
<div class="form-group">
<label class="form-label">Datei (JPG, PNG, PDF)</label>
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
</div>
` : '';
const body = `
<form id="health-form" autocomplete="off">
${commonFields}
${extraFields}
${notizField}
${uploadField}
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
<button type="submit" form="health-form" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Erstellen'}</button>
`;
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();
});
}, 150);
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
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.');
}
// Datei-Upload für Dokumente
if (t === 'dokument' && form.querySelector('[name="datei"]')?.files[0]) {
try {
const formData = new FormData();
formData.append('file', form.querySelector('[name="datei"]').files[0]);
const res = await API.health.uploadDokument(_appState.activeDog.id, saved.id, formData);
saved.datei_url = res.datei_url;
saved.datei_typ = res.datei_typ;
} catch {
UI.toast.warning('Eintrag erstellt, Datei 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:1fr 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:1fr 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 = '';
});
});
}
// ----------------------------------------------------------
// 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;');
}
return { init, refresh, openNew, onDogChange };
})();