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