Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations
Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil
Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware
Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
1964 lines
88 KiB
JavaScript
1964 lines
88 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';
|
||
|
||
const BASE_TABS = [
|
||
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
||
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
|
||
{ key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
|
||
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
|
||
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
|
||
];
|
||
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
|
||
|
||
function _getTabs() {
|
||
const tabs = [...BASE_TABS];
|
||
if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB);
|
||
return tabs;
|
||
}
|
||
|
||
|
||
// ----------------------------------------------------------
|
||
// LIFECYCLE
|
||
// ----------------------------------------------------------
|
||
async function init(container, appState, params) {
|
||
_container = container;
|
||
_appState = appState;
|
||
if (params?.tab) {
|
||
const valid = _getTabs().some(t => t.key === params.tab);
|
||
if (valid) _activeTab = params.tab;
|
||
}
|
||
await _render();
|
||
if (params?.openForm) {
|
||
setTimeout(() => _showForm(null, _activeTab), 200);
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
if (!_appState.activeDog) return;
|
||
if (_appState.dogs.length > 1) {
|
||
_renderDogPicker();
|
||
return;
|
||
}
|
||
_data = {};
|
||
await _renderHealth();
|
||
}
|
||
|
||
async function onDogChange() {
|
||
_data = {};
|
||
await _renderHealth();
|
||
}
|
||
|
||
function openNew() {
|
||
_showForm(null, _activeTab);
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// RENDER — Einstieg: Picker bei mehreren Hunden, sonst direkt
|
||
// ----------------------------------------------------------
|
||
async function _render() {
|
||
if (!_appState.activeDog) {
|
||
_container.innerHTML = UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
|
||
title: 'Noch kein Hund angelegt',
|
||
text: 'Erstelle zuerst ein Hundeprofil.',
|
||
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (_appState.dogs.length > 1) {
|
||
_renderDogPicker();
|
||
} else {
|
||
await _renderHealth();
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HUNDE-PICKER
|
||
// ----------------------------------------------------------
|
||
function _renderDogPicker() {
|
||
const activeDogId = _appState.activeDog?.id;
|
||
|
||
const cards = _appState.dogs.map(dog => {
|
||
const isActive = dog.id === activeDogId;
|
||
const av = dog.foto_url
|
||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
|
||
: `<span>${UI.icon('dog')}</span>`;
|
||
return `
|
||
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
||
data-dog-id="${dog.id}">
|
||
<div class="diary-picker-av">${av}</div>
|
||
<div class="diary-picker-name">${_esc(dog.name)}</div>
|
||
${dog.rasse ? `<div class="diary-picker-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
_container.innerHTML = `
|
||
<div class="diary-picker-wrap">
|
||
<p class="diary-picker-hint">Wessen Gesundheitsakte?</p>
|
||
<div class="diary-picker-grid">${cards}</div>
|
||
</div>`;
|
||
|
||
_container.querySelectorAll('.diary-picker-card').forEach(el => {
|
||
el.addEventListener('click', async () => {
|
||
const id = parseInt(el.dataset.dogId);
|
||
if (id === _appState.activeDog?.id) {
|
||
// Bereits aktiver Hund → direkt Health laden
|
||
_data = {};
|
||
await _renderHealth();
|
||
} else {
|
||
App.setActiveDog(id);
|
||
// onDogChange() → _renderHealth() via _notifyDogChange()
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HEALTH-ANSICHT — Tabs mit Einträgen
|
||
// ----------------------------------------------------------
|
||
async function _renderHealth() {
|
||
const dog = _appState.activeDog;
|
||
const transponderHtml = `
|
||
<div class="health-transponder" id="health-transponder">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg>
|
||
<span class="health-transponder-label">Transponder:</span>
|
||
<span class="health-transponder-nr" id="health-transponder-nr">
|
||
${dog?.chip_nr ? `<strong>${_esc(dog.chip_nr)}</strong>` : '<em style="color:var(--c-text-muted)">nicht eingetragen</em>'}
|
||
</span>
|
||
<button class="btn btn-link btn-sm health-transponder-edit" id="health-transponder-edit"
|
||
style="padding:0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
||
</button>
|
||
</div>`;
|
||
_container.innerHTML = `
|
||
<div class="by-toolbar health-header">
|
||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||
${UI.icon('star')} KI-Zusammenfassung
|
||
</button>
|
||
</div>
|
||
${transponderHtml}
|
||
<div id="health-reminders"></div>
|
||
<div class="by-tabs" id="by-tabs"></div>
|
||
<div id="by-tab-content"></div>
|
||
`;
|
||
|
||
_renderTabBar();
|
||
_container.querySelector('#health-ki-btn')
|
||
.addEventListener('click', _showKiSummary);
|
||
_container.querySelector('#health-transponder-edit')
|
||
.addEventListener('click', () => _editTransponder(dog));
|
||
|
||
await _loadAll();
|
||
_renderErinnerungen();
|
||
_renderTab();
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// ERINNERUNGEN — Banner über den Tabs
|
||
// ----------------------------------------------------------
|
||
function _getErinnerungen() {
|
||
const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament', 'laeufigkeit'];
|
||
const now = Date.now();
|
||
const items = [];
|
||
REMINDER_TABS.forEach(typ => {
|
||
(_data[typ] || []).forEach(e => {
|
||
if (!e.naechstes) return;
|
||
const tage = Math.ceil((new Date(e.naechstes).getTime() - now) / 86400000);
|
||
if (tage <= 60) items.push({ ...e, _tage: tage, _typ: typ });
|
||
});
|
||
});
|
||
return items.sort((a, b) => a._tage - b._tage);
|
||
}
|
||
|
||
function _renderErinnerungen() {
|
||
const el = _container.querySelector('#health-reminders');
|
||
if (!el) return;
|
||
|
||
const items = _getErinnerungen();
|
||
|
||
// Nav-Badge aktualisieren (Anzahl überfälliger/bald fälliger Einträge)
|
||
const overdueCount = items.filter(e => e._tage < 0).length;
|
||
_updateHealthBadge(overdueCount || (items.length ? items.length : 0));
|
||
|
||
if (!items.length) { el.innerHTML = ''; return; }
|
||
|
||
const ICONS = {
|
||
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
|
||
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
|
||
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
|
||
laeufigkeit: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>',
|
||
};
|
||
|
||
el.innerHTML = `
|
||
<div style="padding:var(--space-3) var(--space-4) 0">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Anstehende Erinnerungen
|
||
</div>
|
||
${items.map(e => {
|
||
const ampel = _impfAmpel(e.naechstes);
|
||
const dateStr = UI.time.format(e.naechstes + 'T00:00:00');
|
||
const ageLabel = e._tage < 0
|
||
? `Überfällig seit ${Math.abs(e._tage)} Tagen`
|
||
: e._tage === 0 ? 'Heute fällig'
|
||
: `In ${e._tage} Tagen`;
|
||
return `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-1);
|
||
background:var(--c-surface);border-radius:var(--radius-md);
|
||
border-left:3px solid ${ampel.color === 'red' ? '#ef4444' : ampel.color === 'yellow' ? '#f59e0b' : '#22c55e'}">
|
||
<span style="font-size:1.2rem">${ICONS[e._typ] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>'}</span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-medium);
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
${_esc(e.bezeichnung)}
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
${ageLabel} · ${dateStr}
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-sm ${e._tage < 0 ? 'btn-primary' : 'btn-secondary'}"
|
||
data-action="reminder-erledigt"
|
||
data-entry-id="${e.id}" data-entry-typ="${e._typ}"
|
||
style="flex-shrink:0;white-space:nowrap">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Erledigt
|
||
</button>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
el.querySelectorAll('[data-action="reminder-erledigt"]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const id = parseInt(btn.dataset.entryId);
|
||
const typ = btn.dataset.entryTyp;
|
||
const entry = (_data[typ] || []).find(e => e.id === id);
|
||
if (!entry) return;
|
||
// Neues Formular ohne id → Neu-Eintrag, vorausgefüllt
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
let naechstes = '';
|
||
if (entry.intervall_tage) {
|
||
const next = new Date();
|
||
next.setDate(next.getDate() + entry.intervall_tage);
|
||
naechstes = next.toISOString().slice(0, 10);
|
||
}
|
||
_showForm({
|
||
bezeichnung: entry.bezeichnung,
|
||
datum: today,
|
||
naechstes,
|
||
intervall_tage: entry.intervall_tage,
|
||
tierarzt_id: entry.tierarzt_id,
|
||
tierarzt_name: entry.tierarzt_name,
|
||
charge_nr: entry.charge_nr,
|
||
}, typ);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _updateHealthBadge(count) {
|
||
['[data-page="health"] .nav-item-icon',
|
||
'[data-page="health"] .sidebar-item-icon'].forEach(sel => {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
let badge = el.querySelector('.nav-badge');
|
||
if (count > 0) {
|
||
if (!badge) {
|
||
badge = document.createElement('span');
|
||
badge.className = 'nav-badge';
|
||
el.appendChild(badge);
|
||
}
|
||
badge.textContent = count > 9 ? '9+' : count;
|
||
} else if (badge) {
|
||
badge.remove();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function _renderTabBar() {
|
||
const tabsEl = _container.querySelector('#by-tabs');
|
||
tabsEl.innerHTML = _getTabs().map(t => `
|
||
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
|
||
data-tab="${t.key}">
|
||
${t.icon} ${t.label}
|
||
</button>
|
||
`).join('');
|
||
const count = tabsEl.querySelectorAll('.by-tab').length;
|
||
tabsEl.style.setProperty('--health-tab-cols', Math.ceil(count / 2));
|
||
tabsEl.querySelectorAll('.by-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_activeTab = btn.dataset.tab;
|
||
tabsEl.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
_renderTab();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DATEN LADEN
|
||
// ----------------------------------------------------------
|
||
async function _loadAll() {
|
||
const dogId = _appState.activeDog.id;
|
||
try {
|
||
const all = await API.health.list(dogId);
|
||
_data = {};
|
||
_getTabs().forEach(t => { _data[t.key] = []; });
|
||
_data['laeufigkeit'] = _data['laeufigkeit'] || [];
|
||
all.forEach(e => {
|
||
if (_data[e.typ] !== undefined) _data[e.typ].push(e);
|
||
});
|
||
} catch (err) {
|
||
UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.');
|
||
}
|
||
try {
|
||
_praxen = await API.tieraerzte.list();
|
||
} catch (err) {
|
||
// silent fail
|
||
}
|
||
try {
|
||
_data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId);
|
||
} catch (err) {
|
||
_data['gewicht_chart'] = [];
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// TAB-INHALT RENDERN
|
||
// ----------------------------------------------------------
|
||
function _renderTab() {
|
||
const content = _container.querySelector('#by-tab-content');
|
||
if (!content) return;
|
||
|
||
const entries = _data[_activeTab] || [];
|
||
|
||
switch (_activeTab) {
|
||
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
|
||
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
|
||
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
|
||
case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break;
|
||
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
|
||
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||
case 'symptomcheck': _renderSymptomCheck(content); break;
|
||
}
|
||
|
||
_bindTabEvents(content);
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// EMPTY-STATE HELPER
|
||
// ----------------------------------------------------------
|
||
function _emptyState(icon, title, text, cta = '') {
|
||
return `<div class="empty-state">
|
||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||
</svg>
|
||
<div class="empty-state-title">${title}</div>
|
||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// IMPFUNGEN — mit Ampel-Status
|
||
// ----------------------------------------------------------
|
||
function _renderImpfungen(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
|
||
const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
|
||
|
||
if (!entries.length) return _emptyState(
|
||
'syringe',
|
||
'Noch keine Impfungen',
|
||
`Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
|
||
addBtn
|
||
);
|
||
|
||
const items = entries.map(e => {
|
||
const ampel = _impfAmpel(e.naechstes);
|
||
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
|
||
const vetName = praxis?.name || e.tierarzt_name || '';
|
||
return `
|
||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
||
<div class="health-card-ampel ampel-${ampel.color}" title="${ampel.label}"></div>
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
||
<div class="health-card-meta">
|
||
${UI.time.format(e.datum + 'T00:00:00')}
|
||
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
|
||
</div>
|
||
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(vetName)}</div>` : ''}
|
||
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
|
||
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
|
||
</div>` : ''}
|
||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="${_esc(e.bezeichnung)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `<div class="health-list">${items}</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||
}
|
||
|
||
function _impfAmpel(naechstesStr) {
|
||
if (!naechstesStr) return { color: 'grey', label: 'Kein Folgedatum', icon: '' };
|
||
const diff = (new Date(naechstesStr) - Date.now()) / 86400000; // Tage
|
||
if (diff < 0) return { color: 'red', label: 'Überfällig!', icon: '🔴' };
|
||
if (diff < 60) return { color: 'yellow', label: 'Bald fällig', icon: '🟡' };
|
||
return { color: 'green', label: 'Aktuell', icon: '🟢' };
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// TIERARZTBESUCHE
|
||
// ----------------------------------------------------------
|
||
function _renderTierarzt(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
|
||
if (!entries.length) return UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
|
||
});
|
||
|
||
const items = entries.map(e => {
|
||
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
|
||
const praxisName = praxis?.name || e.tierarzt_name || '';
|
||
const praxisOrt = praxis ? [praxis.plz, praxis.ort].filter(Boolean).join(' ') : '';
|
||
return `
|
||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
||
<div class="health-card-meta">
|
||
${UI.time.format(e.datum + 'T00:00:00')}
|
||
${e.kosten != null ? ` · ${Number(e.kosten).toFixed(2)} €` : ''}
|
||
</div>
|
||
${praxisName ? `
|
||
<div style="display:flex;align-items:center;gap:var(--space-1);
|
||
margin-top:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
|
||
</div>` : ''}
|
||
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
|
||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="${_esc(e.bezeichnung)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `<div class="health-list">${items}</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// GEWICHT — Großanzeige + SVG-Diagramm
|
||
// ----------------------------------------------------------
|
||
function _renderGewicht(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
|
||
|
||
if (!entries.length) return UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>', title: 'Noch keine Gewichtseinträge', action: addBtn
|
||
});
|
||
|
||
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
|
||
const latest = sorted[sorted.length - 1];
|
||
const prev = sorted.length >= 2 ? sorted[sorted.length - 2] : null;
|
||
const delta = prev ? (parseFloat(latest.wert) - parseFloat(prev.wert)) : null;
|
||
|
||
const deltaHtml = delta !== null ? (() => {
|
||
const sign = delta > 0 ? '+' : '';
|
||
const color = delta > 0 ? 'var(--c-warning)' : delta < 0 ? 'var(--c-success)' : 'var(--c-text-muted)';
|
||
const arrow = delta > 0 ? '▲' : delta < 0 ? '▼' : '→';
|
||
return `<div style="font-size:var(--text-sm);color:${color};margin-top:var(--space-1)">
|
||
${arrow} ${sign}${delta.toFixed(1)} kg seit letzter Messung
|
||
</div>`;
|
||
})() : '';
|
||
|
||
const chartEntries = _data['gewicht_chart'] || [];
|
||
const chart = _renderWeightChart(chartEntries);
|
||
|
||
const items = sorted.slice().reverse().map(e => `
|
||
<div class="health-card" data-id="${e.id}" data-action="open-entry"
|
||
style="padding:var(--space-3) var(--space-4)">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
|
||
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
${UI.time.format(e.datum + 'T00:00:00')}
|
||
</span>
|
||
<span style="font-weight:var(--weight-bold);font-size:var(--text-lg)">
|
||
${e.wert} <span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${e.einheit || 'kg'}</span>
|
||
</span>
|
||
</div>
|
||
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="Gewicht ${_esc(e.datum)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
return `
|
||
<div style="text-align:center;padding:var(--space-5) var(--space-4) var(--space-2)">
|
||
<div style="font-size:3rem;font-weight:var(--weight-bold);color:var(--c-text);line-height:1">
|
||
${latest.wert}
|
||
<span style="font-size:1.1rem;font-weight:var(--weight-normal);color:var(--c-text-secondary)">kg</span>
|
||
</div>
|
||
${deltaHtml}
|
||
</div>
|
||
${chart ? `<div class="health-chart-wrap">
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
padding:var(--space-2) var(--space-3) 0;display:flex;align-items:center;gap:var(--space-1)">
|
||
Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
|
||
</div>
|
||
${chart}
|
||
</div>` : ''}
|
||
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||
`;
|
||
}
|
||
|
||
function _weightChart(entries) {
|
||
const W = 340, H = 160, PX = 36, PY = 16, PB = 28;
|
||
const vals = entries.map(e => parseFloat(e.wert));
|
||
const minV = Math.min(...vals);
|
||
const maxV = Math.max(...vals);
|
||
const range = maxV - minV || 1;
|
||
const innerW = W - PX * 2;
|
||
const innerH = H - PY - PB;
|
||
|
||
const toX = i => PX + (i / (entries.length - 1)) * innerW;
|
||
const toY = v => PY + (1 - (v - minV) / range) * innerH;
|
||
const pts = entries.map((e, i) => [toX(i), toY(parseFloat(e.wert))]);
|
||
|
||
// Smooth bezier path
|
||
const linePath = pts.reduce((acc, [x, y], i) => {
|
||
if (i === 0) return `M ${x},${y}`;
|
||
const [px, py] = pts[i - 1];
|
||
const cpx = (px + x) / 2;
|
||
return `${acc} C ${cpx},${py} ${cpx},${y} ${x},${y}`;
|
||
}, '');
|
||
|
||
const fillPath = `${linePath} L ${pts[pts.length - 1][0]},${H - PB} L ${pts[0][0]},${H - PB} Z`;
|
||
|
||
// Grid lines
|
||
const gridLines = [0, 0.5, 1].map(frac => {
|
||
const v = maxV - frac * range;
|
||
const y = toY(v);
|
||
return `
|
||
<line x1="${PX}" y1="${y}" x2="${W - PX}" y2="${y}"
|
||
stroke="var(--c-border-light)" stroke-width="1" stroke-dasharray="4,4"/>
|
||
<text x="${PX - 5}" y="${y + 4}" font-size="9" fill="var(--c-text-muted)" text-anchor="end">
|
||
${v.toFixed(1)}
|
||
</text>`;
|
||
}).join('');
|
||
|
||
// X-axis labels
|
||
const xIdxs = entries.length <= 3
|
||
? entries.map((_, i) => i)
|
||
: [0, Math.round((entries.length - 1) / 2), entries.length - 1];
|
||
const xLabels = [...new Set(xIdxs)].map(i => {
|
||
const [m, d] = entries[i].datum.slice(5).split('-');
|
||
return `<text x="${toX(i)}" y="${H - 6}" font-size="9" fill="var(--c-text-muted)"
|
||
text-anchor="middle">${d}.${m}.</text>`;
|
||
}).join('');
|
||
|
||
// Dots
|
||
const dots = pts.map(([x, y], i) => {
|
||
const isLast = i === pts.length - 1;
|
||
return `<circle cx="${x}" cy="${y}" r="${isLast ? 5 : 3.5}"
|
||
fill="${isLast ? 'var(--c-primary)' : 'var(--c-surface)'}"
|
||
stroke="var(--c-primary)" stroke-width="2"/>`;
|
||
}).join('');
|
||
|
||
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
|
||
return `
|
||
<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">
|
||
<defs>
|
||
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.22"/>
|
||
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
|
||
</linearGradient>
|
||
</defs>
|
||
${gridLines}
|
||
<path d="${fillPath}" fill="url(#${gId})"/>
|
||
<path d="${linePath}" fill="none" stroke="var(--c-primary)"
|
||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
${dots}
|
||
${xLabels}
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht)
|
||
// ----------------------------------------------------------
|
||
function _renderWeightChart(entries) {
|
||
// entries: [{datum, gewicht}, ...]
|
||
if (!entries || entries.length < 2) {
|
||
return '<p class="health-chart-empty">Mindestens 2 Gewichtseinträge für den Verlauf nötig.</p>';
|
||
}
|
||
|
||
const W = 300, H = 120, PAD = 24;
|
||
const weights = entries.map(e => e.gewicht);
|
||
const min = Math.min(...weights), max = Math.max(...weights);
|
||
const range = max - min || 1;
|
||
|
||
// x: gleichmäßig verteilt, y: normalisiert
|
||
const pts = entries.map((e, i) => {
|
||
const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD);
|
||
const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD);
|
||
return { x, y, ...e };
|
||
});
|
||
|
||
const polyline = pts.map(p => `${p.x},${p.y}`).join(' ');
|
||
const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`;
|
||
|
||
// Datenpunkte + Tooltips als title-Elemente
|
||
const circles = pts.map(p =>
|
||
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--c-primary)">
|
||
<title>${p.datum}: ${p.gewicht} kg</title>
|
||
</circle>`
|
||
).join('');
|
||
|
||
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
|
||
return `
|
||
<div class="health-chart-wrap">
|
||
<div class="health-chart-title">Gewichtsverlauf</div>
|
||
<svg viewBox="0 0 ${W} ${H}" class="health-chart-svg" aria-label="Gewichtsverlauf">
|
||
<defs>
|
||
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity=".25"/>
|
||
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<polygon points="${area}" fill="url(#${gId})"/>
|
||
<polyline points="${polyline}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||
${circles}
|
||
<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${min}</text>
|
||
<text x="${PAD - 2}" y="${PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${max}</text>
|
||
</svg>
|
||
<div class="health-chart-labels">
|
||
<span>${entries[0].datum}</span>
|
||
<span>${entries[entries.length - 1].datum}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// LÄUFIGKEIT — Timeline + Vorhersage
|
||
// ----------------------------------------------------------
|
||
function _renderLaeufigkeit(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">${UI.icon('plus')} Läufigkeit eintragen</button>`;
|
||
|
||
const sorted = [...entries].sort((a, b) => b.datum.localeCompare(a.datum)); // neueste zuerst
|
||
|
||
// Durchschnittlicher Abstand berechnen
|
||
let avgInterval = null;
|
||
if (sorted.length >= 2) {
|
||
const asc = [...sorted].reverse();
|
||
const diffs = [];
|
||
for (let i = 1; i < asc.length; i++) {
|
||
diffs.push(Math.round((new Date(asc[i].datum) - new Date(asc[i-1].datum)) / 86400000));
|
||
}
|
||
avgInterval = Math.round(diffs.reduce((a, b) => a + b, 0) / diffs.length);
|
||
}
|
||
|
||
// Nächste vorhergesagte Läufigkeit
|
||
const last = sorted[0];
|
||
let nextPrediction = null;
|
||
if (last?.naechstes) {
|
||
nextPrediction = last.naechstes;
|
||
} else if (last?.datum && (last?.intervall_tage || avgInterval)) {
|
||
const iv = last.intervall_tage || avgInterval;
|
||
const d = new Date(last.datum);
|
||
d.setDate(d.getDate() + iv);
|
||
nextPrediction = d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
// Banner für nächste Läufigkeit
|
||
let banner = '';
|
||
if (nextPrediction) {
|
||
const ampel = _impfAmpel(nextPrediction);
|
||
const tage = Math.ceil((new Date(nextPrediction) - Date.now()) / 86400000);
|
||
const label = tage < 0 ? `Überfällig seit ${Math.abs(tage)} Tagen`
|
||
: tage === 0 ? 'Könnte heute beginnen'
|
||
: tage <= 14 ? `In ${tage} Tagen`
|
||
: UI.time.format(nextPrediction + 'T00:00:00');
|
||
banner = `
|
||
<div style="margin:var(--space-4) var(--space-4) 0;padding:var(--space-3) var(--space-4);
|
||
background:var(--c-surface);border-radius:var(--radius-lg);
|
||
border-left:4px solid ${ampel.color === 'red' ? 'var(--c-danger)' : ampel.color === 'yellow' ? 'var(--c-warning)' : 'var(--c-primary)'};
|
||
display:flex;align-items:center;gap:var(--space-3)">
|
||
<span style="font-size:1.5rem">${UI.icon('gender-female')}</span>
|
||
<div>
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Nächste Läufigkeit erwartet</div>
|
||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${label}
|
||
${avgInterval ? ` · Ø ${avgInterval} Tage Abstand` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (!sorted.length) return `
|
||
${UI.emptyState({
|
||
icon: UI.icon('gender-female'),
|
||
title: 'Noch keine Läufigkeit eingetragen',
|
||
text: 'Trage Läufigkeiten ein, um den Zyklus zu verfolgen.',
|
||
action: addBtn,
|
||
})}`;
|
||
|
||
const items = sorted.map((e, i) => {
|
||
const prev = sorted[i + 1];
|
||
const interval = prev
|
||
? Math.round((new Date(e.datum) - new Date(prev.datum)) / 86400000)
|
||
: null;
|
||
return `
|
||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
||
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
|
||
display:flex;align-items:center;justify-content:center;
|
||
flex-shrink:0;color:var(--c-text-inverse)">
|
||
${UI.icon('gender-female')}
|
||
</div>
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">Läufigkeit · ${UI.time.format(e.datum + 'T00:00:00')}</div>
|
||
<div class="health-card-meta">
|
||
${e.wert ? `Dauer: ${e.wert} Tage` : 'Dauer nicht angegeben'}
|
||
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
|
||
</div>
|
||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="Läufigkeit ${_esc(e.datum)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
return `
|
||
${banner}
|
||
<div class="health-list" style="margin-top:var(--space-4)">${items}</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// MEDIKAMENTE
|
||
// ----------------------------------------------------------
|
||
function _renderMedikamente(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Medikament eintragen</button>`;
|
||
if (!entries.length) return UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Medikamente', action: addBtn
|
||
});
|
||
|
||
const aktive = entries.filter(e => e.aktiv);
|
||
const inaktive = entries.filter(e => !e.aktiv);
|
||
|
||
const renderGroup = (items, label) => items.length ? `
|
||
<div class="by-section-label">${label}</div>
|
||
${items.map(e => `
|
||
<div class="health-card${e.aktiv ? '' : ' health-card--inactive'}" data-id="${e.id}" data-action="open-entry">
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
||
<div class="health-card-meta">
|
||
${e.dosierung ? _esc(e.dosierung) : ''}
|
||
${e.haeufigkeit ? ` · ${_esc(e.haeufigkeit)}` : ''}
|
||
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
|
||
</div>
|
||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="${_esc(e.bezeichnung)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
` : '';
|
||
|
||
return `
|
||
<div class="health-list">
|
||
${renderGroup(aktive, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Aktuelle Medikamente')}
|
||
${renderGroup(inaktive, 'Vergangene Medikamente')}
|
||
</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// ALLERGIEN
|
||
// ----------------------------------------------------------
|
||
function _renderAllergien(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Allergie eintragen</button>`;
|
||
if (!entries.length) return UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', title: 'Noch keine Allergien eingetragen', action: addBtn
|
||
});
|
||
|
||
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
|
||
|
||
const items = entries.map(e => `
|
||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">
|
||
${e.schweregrad ? SCHWEREGRAD[e.schweregrad] || '' : ''} ${_esc(e.bezeichnung)}
|
||
</div>
|
||
<div class="health-card-meta">
|
||
Erstmals: ${UI.time.format(e.datum + 'T00:00:00')}
|
||
${e.schweregrad ? ` · Schweregrad: ${_esc(e.schweregrad)}` : ''}
|
||
</div>
|
||
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
|
||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="${_esc(e.bezeichnung)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
return `<div class="health-list">${items}</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DOKUMENTE
|
||
// ----------------------------------------------------------
|
||
function _renderDokumente(entries) {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
|
||
if (!entries.length) return UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
|
||
});
|
||
|
||
const items = entries.map(e => {
|
||
// media_items bevorzugen, legacy datei_url als Fallback
|
||
const mediaList = e.media_items?.length
|
||
? e.media_items
|
||
: (e.datei_url ? [{ id: null, url: e.datei_url, media_type: e.datei_typ || 'image' }] : []);
|
||
const firstImg = mediaList.find(m => m.media_type !== 'pdf');
|
||
const hasPdf = mediaList.some(m => m.media_type === 'pdf');
|
||
const count = mediaList.length;
|
||
|
||
return `
|
||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
||
${firstImg
|
||
? `<img src="${_esc(firstImg.url)}" class="health-doc-thumb" alt="Vorschau"
|
||
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
|
||
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
|
||
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
||
<div class="health-card-meta">
|
||
${UI.time.format(e.datum + 'T00:00:00')}
|
||
${count > 1 ? ` · ${count} Dateien` : ''}
|
||
</div>
|
||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||
data-action="open-note" data-entry-id="${e.id}"
|
||
data-label="${_esc(e.bezeichnung)}"
|
||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||
${count
|
||
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
|
||
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
|
||
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
|
||
class="btn btn-secondary btn-sm" style="display:inline-flex"
|
||
onclick="event.stopPropagation()">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF
|
||
</a>`
|
||
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
|
||
class="btn btn-secondary btn-sm" style="display:inline-flex"
|
||
onclick="event.stopPropagation()">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild
|
||
</a>`
|
||
).join('')}
|
||
</div>`
|
||
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `<div class="health-list">${items}</div>
|
||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// EVENTS BINDEN
|
||
// ----------------------------------------------------------
|
||
function _bindTabEvents(content) {
|
||
content.querySelectorAll('[data-action="add-entry"]').forEach(btn => {
|
||
btn.addEventListener('click', () => _showForm(null, _activeTab));
|
||
});
|
||
content.querySelectorAll('[data-action="open-entry"]').forEach(card => {
|
||
const id = parseInt(card.dataset.id);
|
||
const entry = (_data[_activeTab] || []).find(e => e.id === id);
|
||
if (entry) card.addEventListener('click', () => _openDetail(entry));
|
||
});
|
||
content.querySelectorAll('[data-action="open-note"]').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const id = parseInt(btn.dataset.entryId);
|
||
const label = btn.dataset.label || '';
|
||
_openNoteModal('health', id, label, null);
|
||
});
|
||
});
|
||
// Praxis öffnen
|
||
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const id = parseInt(el.dataset.praxisId);
|
||
const p = _praxen.find(x => x.id === id);
|
||
if (p) _showPraxForm(p);
|
||
});
|
||
});
|
||
// Dokument löschen
|
||
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = parseInt(btn.dataset.id);
|
||
const dogId = _appState.activeDog.id;
|
||
const ok = await UI.modal.confirm({
|
||
title: 'Dokument löschen',
|
||
text: 'Die Datei wird unwiderruflich gelöscht.',
|
||
confirmText: 'Löschen',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.health.deleteDocument(dogId, id);
|
||
const list = _data[_activeTab] || [];
|
||
const entry = list.find(e => e.id === id);
|
||
if (entry) { entry.datei_url = null; entry.datei_typ = null; }
|
||
_renderTab();
|
||
UI.toast.success('Dokument gelöscht.');
|
||
});
|
||
});
|
||
});
|
||
// Praxis hinzufügen
|
||
content.querySelector('[data-action="add-praxis"]')
|
||
?.addEventListener('click', () => _showPraxForm(null));
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DETAIL-ANSICHT
|
||
// ----------------------------------------------------------
|
||
function _openDetail(entry) {
|
||
const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0];
|
||
const fields = _detailFields(entry);
|
||
|
||
// Media-Items zusammenstellen (neue + legacy)
|
||
const mediaItems = entry.media_items?.length
|
||
? entry.media_items
|
||
: (entry.datei_url ? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image' }] : []);
|
||
|
||
const mediaHtml = mediaItems.length
|
||
? `<div class="health-media-gallery" style="margin-top:var(--space-4)">
|
||
${mediaItems.map(m => m.media_type === 'pdf'
|
||
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
|
||
class="btn btn-secondary btn-sm health-media-gallery-pdf">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen
|
||
</a>`
|
||
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener" class="health-media-gallery-img">
|
||
<img src="${_esc(m.url)}" alt="Bild" loading="lazy">
|
||
</a>`
|
||
).join('')}
|
||
</div>`
|
||
: '';
|
||
|
||
const body = `
|
||
<div class="health-detail">
|
||
${fields}
|
||
${mediaHtml}
|
||
</div>
|
||
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
|
||
`;
|
||
|
||
const modalTitle = entry.typ === 'gewicht'
|
||
? `${tabInfo.icon} ${entry.wert} ${entry.einheit || 'kg'}`
|
||
: entry.typ === 'laeufigkeit'
|
||
? `${tabInfo.icon} Läufigkeit ${UI.time.format(entry.datum + 'T00:00:00')}`
|
||
: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`;
|
||
UI.modal.open({ title: modalTitle, body });
|
||
|
||
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
|
||
UI.modal.close();
|
||
_showForm(entry, entry.typ);
|
||
});
|
||
}
|
||
|
||
function _detailFields(e) {
|
||
const rows = [];
|
||
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
|
||
if (e.typ === 'laeufigkeit' && e.wert) rows.push(['Dauer', `${e.wert} Tage`]);
|
||
if (e.naechstes) rows.push([e.typ === 'laeufigkeit' ? 'Nächste erwartet' : 'Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
|
||
if (e.tierarzt_id) {
|
||
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
|
||
if (praxis) {
|
||
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
|
||
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
|
||
}
|
||
} else if (e.tierarzt_name) {
|
||
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
|
||
}
|
||
if (e.charge_nr) rows.push(['Charge-Nr.', _esc(e.charge_nr)]);
|
||
if (e.kosten != null) rows.push(['Kosten', `${Number(e.kosten).toFixed(2)} €`]);
|
||
if (e.diagnose) rows.push(['Diagnose', _esc(e.diagnose)]);
|
||
if (e.wert) rows.push(['Gewicht', `${e.wert} ${e.einheit || 'kg'}`]);
|
||
if (e.dosierung) rows.push(['Dosierung', _esc(e.dosierung)]);
|
||
if (e.haeufigkeit) rows.push(['Häufigkeit', _esc(e.haeufigkeit)]);
|
||
if (e.bis_datum) rows.push(['Bis', UI.time.format(e.bis_datum + 'T00:00:00')]);
|
||
if (e.schweregrad) rows.push(['Schweregrad',_esc(e.schweregrad)]);
|
||
if (e.reaktion) rows.push(['Reaktion', _esc(e.reaktion)]);
|
||
if (e.notiz) rows.push(['Notiz', _esc(e.notiz)]);
|
||
|
||
return `<dl class="health-detail-dl">${
|
||
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
|
||
}</dl>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// FORMULAR — Neu / Bearbeiten
|
||
// ----------------------------------------------------------
|
||
function _showForm(entry, typ) {
|
||
const isEdit = !!(entry?.id);
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const t = typ || _activeTab;
|
||
|
||
const commonFields = `
|
||
${t !== 'gewicht' && t !== 'laeufigkeit' ? `
|
||
<div class="form-group">
|
||
<label class="form-label">Bezeichnung *</label>
|
||
<input class="form-control" type="text" name="bezeichnung"
|
||
value="${_esc(entry?.bezeichnung || '')}" required
|
||
placeholder="${_formPlaceholder(t)}">
|
||
</div>` : ''}
|
||
<div class="form-group">
|
||
<label class="form-label">${t === 'laeufigkeit' ? 'Beginn der Läufigkeit *' : 'Start *'}</label>
|
||
<input class="form-control" type="date" name="datum"
|
||
value="${entry?.datum || today}" required>
|
||
</div>
|
||
`;
|
||
|
||
const extraFields = _extraFormFields(entry, t);
|
||
const notizField = `
|
||
<div class="form-group">
|
||
<label class="form-label">Notiz</label>
|
||
<textarea class="form-control" name="notiz" rows="3">${_esc(entry?.notiz || '')}</textarea>
|
||
</div>
|
||
`;
|
||
|
||
// Multi-Upload-Bereich — zeige vorhandene media_items + neuen Upload
|
||
const existingMedia = (entry?.media_items || []);
|
||
const legacyFile = (!existingMedia.length && entry?.datei_url)
|
||
? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image', _legacy: true }]
|
||
: [];
|
||
const allMedia = [...existingMedia, ...legacyFile];
|
||
|
||
const mediaThumbsHtml = allMedia.map(m => {
|
||
const isImg = m.media_type !== 'pdf';
|
||
const removeBtn = m.id
|
||
? `<button type="button" class="health-media-remove" data-media-id="${m.id}"
|
||
title="Entfernen" aria-label="Datei entfernen">×</button>`
|
||
: '';
|
||
return `<div class="health-media-thumb" data-media-id="${m.id || ''}">
|
||
${isImg
|
||
? `<img src="${_esc(m.url)}" alt="Vorschau">`
|
||
: `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div>`}
|
||
${removeBtn}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const uploadField = `
|
||
<div class="form-group" id="health-media-section">
|
||
<label class="form-label">
|
||
Dateien (Bilder / PDFs)
|
||
${UI.help('Befunde, Röntgenbilder, Laborwerte — mehrere Dateien möglich.')}
|
||
</label>
|
||
<div class="health-media-grid" id="health-media-grid">${mediaThumbsHtml}</div>
|
||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei hinzufügen
|
||
<input type="file" name="datei_neu" accept="image/*,.pdf" multiple
|
||
style="position:absolute;opacity:0;width:1px;height:1px" id="health-file-input">
|
||
</label>
|
||
<div id="health-file-pending" style="margin-top:var(--space-2);display:flex;flex-wrap:wrap;gap:var(--space-2)"></div>
|
||
</div>
|
||
`;
|
||
|
||
const body = `
|
||
<form id="health-form" autocomplete="off">
|
||
${commonFields}
|
||
${extraFields}
|
||
${notizField}
|
||
${uploadField}
|
||
</form>
|
||
`;
|
||
|
||
const footer = `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||
<button type="submit" form="health-form" class="btn btn-primary" style="width:100%">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
||
<div style="display:flex;gap:var(--space-2)">
|
||
${isEdit ? `<button type="button" class="btn btn-danger" id="health-form-delete">Löschen</button>` : ''}
|
||
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const tabInfo = _getTabs().find(tab => tab.key === t) || BASE_TABS[0];
|
||
UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body, footer });
|
||
|
||
const form = document.getElementById('health-form');
|
||
setTimeout(() => {
|
||
form?.querySelector('[name="bezeichnung"]')?.focus();
|
||
// "Praxis anlegen" Button im Formular
|
||
form?.querySelector('[data-action="goto-praxen"]')?.addEventListener('click', () => {
|
||
UI.modal.close();
|
||
_activeTab = 'praxen';
|
||
_renderTab();
|
||
});
|
||
// File-Input: Vorschau für ausstehende Uploads
|
||
const fileInput = document.getElementById('health-file-input');
|
||
const pendingBox = document.getElementById('health-file-pending');
|
||
if (fileInput && pendingBox) {
|
||
fileInput.addEventListener('change', () => {
|
||
pendingBox.innerHTML = '';
|
||
Array.from(fileInput.files || []).forEach(f => {
|
||
const isPdf = f.name.toLowerCase().endsWith('.pdf');
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'health-media-thumb health-media-thumb--pending';
|
||
if (isPdf) {
|
||
thumb.innerHTML = `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div><small>${_esc(f.name.slice(0, 18))}</small>`;
|
||
} else {
|
||
const img = document.createElement('img');
|
||
img.src = URL.createObjectURL(f);
|
||
thumb.appendChild(img);
|
||
}
|
||
pendingBox.appendChild(thumb);
|
||
});
|
||
});
|
||
}
|
||
// X-Buttons für vorhandene Media-Items
|
||
document.querySelectorAll('#health-media-grid .health-media-remove').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const mediaId = parseInt(btn.dataset.mediaId);
|
||
const dogId = _appState.activeDog.id;
|
||
if (!mediaId || !entry?.id) return;
|
||
try {
|
||
await API.health.deleteMedia(dogId, entry.id, mediaId);
|
||
// Aus entry.media_items entfernen
|
||
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
|
||
btn.closest('.health-media-thumb').remove();
|
||
// Auch in _data aktualisieren
|
||
const list = _data[t] || [];
|
||
const idx = list.findIndex(x => x.id === entry.id);
|
||
if (idx !== -1) list[idx].media_items = (list[idx].media_items || []).filter(m => m.id !== mediaId);
|
||
UI.toast.success('Datei entfernt.');
|
||
} catch (err) {
|
||
UI.toast.error('Fehler beim Löschen.');
|
||
}
|
||
});
|
||
});
|
||
}, 150);
|
||
|
||
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
|
||
|
||
document.getElementById('health-form-delete')?.addEventListener('click', async () => {
|
||
const ok = await UI.modal.confirm({
|
||
title: 'Eintrag löschen?', message: 'Nicht rückgängig.', confirmText: 'Löschen', danger: true,
|
||
});
|
||
if (ok) {
|
||
try {
|
||
await API.health.delete(_appState.activeDog.id, entry.id);
|
||
_data[t] = (_data[t] || []).filter(e => e.id !== entry.id);
|
||
UI.modal.close();
|
||
_renderTab();
|
||
UI.toast.success('Eintrag gelöscht.');
|
||
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
|
||
}
|
||
});
|
||
|
||
form.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = document.querySelector('[form="health-form"][type="submit"]') || form.querySelector('[type="submit"]');
|
||
const fd = UI.formData(form);
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
const payload = _buildPayload(fd, t);
|
||
|
||
let saved;
|
||
if (isEdit) {
|
||
saved = await API.health.update(_appState.activeDog.id, entry.id, payload);
|
||
const idx = (_data[t] || []).findIndex(x => x.id === entry.id);
|
||
if (idx !== -1) _data[t][idx] = saved;
|
||
UI.toast.success('Gespeichert.');
|
||
} else {
|
||
saved = await API.health.create(_appState.activeDog.id, { ...payload, typ: t });
|
||
if (!_data[t]) _data[t] = [];
|
||
_data[t].unshift(saved);
|
||
UI.toast.success('Eintrag erstellt.');
|
||
if (t === 'gewicht' && saved.wert) {
|
||
_appState.activeDog.gewicht_kg = saved.wert;
|
||
}
|
||
}
|
||
|
||
// Multi-File-Upload
|
||
const fileInput = form.querySelector('[name="datei_neu"]');
|
||
const files = fileInput ? Array.from(fileInput.files || []) : [];
|
||
if (files.length) {
|
||
const dogId = _appState.activeDog.id;
|
||
if (!saved.media_items) saved.media_items = [];
|
||
for (const f of files) {
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', f);
|
||
const res = await API.health.uploadMedia(dogId, saved.id, fd);
|
||
saved.media_items.push({ id: res.id, url: res.url, media_type: res.media_type });
|
||
// Rückwärtskompatibilität: erste Datei auch als datei_url sichern
|
||
if (!saved.datei_url) {
|
||
saved.datei_url = res.url;
|
||
saved.datei_typ = res.media_type;
|
||
}
|
||
} catch {
|
||
UI.toast.warning(`Datei "${f.name}" konnte nicht hochgeladen werden.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
UI.modal.close();
|
||
_renderTab();
|
||
});
|
||
});
|
||
}
|
||
|
||
function _formPlaceholder(typ) {
|
||
const ph = {
|
||
impfung: 'z.B. Tollwut, DHPP, Leptospirose',
|
||
tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung',
|
||
gewicht: '',
|
||
medikament: 'z.B. Frontline, Milbemax',
|
||
allergie: 'z.B. Hühnchen, Gras, Hausstaub',
|
||
dokument: 'z.B. Impfpass, Blutbild',
|
||
laeufigkeit: 'Läufigkeit',
|
||
};
|
||
return ph[typ] || '';
|
||
}
|
||
|
||
// Intervall-Auswahl für wiederkehrende Einträge
|
||
function _intervallField(entry) {
|
||
const v = entry?.intervall_tage;
|
||
const opts = [
|
||
[null, 'Einmalig'],
|
||
[30, 'Monatlich (30 Tage)'],
|
||
[60, 'Alle 2 Monate'],
|
||
[90, 'Vierteljährlich (90 Tage)'],
|
||
[180, 'Halbjährlich'],
|
||
[365, 'Jährlich'],
|
||
];
|
||
return `
|
||
<div class="form-group">
|
||
<label class="form-label">Wiederholt sich</label>
|
||
<select class="form-control" name="intervall_tage">
|
||
${opts.map(([days, label]) =>
|
||
`<option value="${days ?? ''}" ${(v == days) ? 'selected' : ''}>${label}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>`;
|
||
}
|
||
|
||
// Wiederverwendbares Praxis-Dropdown für alle Formulare
|
||
function _praxisSelectField(entry) {
|
||
const aktivePraxen = _praxen.filter(p => p.aktiv);
|
||
if (!aktivePraxen.length) return '';
|
||
return `
|
||
<div class="form-group">
|
||
<label class="form-label">Behandelnde Praxis</label>
|
||
<select class="form-control" name="tierarzt_id">
|
||
<option value="">– optional –</option>
|
||
${aktivePraxen.map(p => `
|
||
<option value="${p.id}" ${entry?.tierarzt_id === p.id ? 'selected' : ''}>
|
||
${_esc(p.name)}${p.ort ? ` · ${_esc(p.ort)}` : ''}
|
||
</option>`).join('')}
|
||
</select>
|
||
</div>`;
|
||
}
|
||
|
||
function _extraFormFields(entry, typ) {
|
||
switch (typ) {
|
||
case 'impfung': return `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">Nächste Impfung</label>
|
||
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
||
</div>
|
||
${_intervallField(entry)}
|
||
</div>
|
||
${_praxisSelectField(entry)}
|
||
<div class="form-group">
|
||
<label class="form-label">Chargen-Nr.</label>
|
||
<input class="form-control" type="text" name="charge_nr" value="${_esc(entry?.charge_nr || '')}">
|
||
</div>
|
||
`;
|
||
case 'entwurmung': return `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">Nächste Behandlung</label>
|
||
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
||
</div>
|
||
${_intervallField(entry)}
|
||
</div>
|
||
${_praxisSelectField(entry)}
|
||
`;
|
||
case 'tierarzt': {
|
||
const aktivePraxen = _praxen.filter(p => p.aktiv);
|
||
const praxisField = aktivePraxen.length
|
||
? `<div class="form-group">
|
||
<label class="form-label">Behandelnde Praxis</label>
|
||
<select class="form-control" id="health-praxis-select" name="tierarzt_id">
|
||
<option value="">– Praxis wählen –</option>
|
||
${aktivePraxen.map(p => `
|
||
<option value="${p.id}"
|
||
${entry?.tierarzt_id === p.id ? 'selected' : ''}>
|
||
${_esc(p.name)}${p.ort ? ` · ${_esc(p.ort)}` : ''}
|
||
</option>`).join('')}
|
||
</select>
|
||
</div>`
|
||
: `<div class="form-group">
|
||
<div style="padding:var(--space-3);background:var(--c-bg);border-radius:var(--radius-md);
|
||
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Noch keine Praxis angelegt —
|
||
<button type="button" class="btn btn-ghost btn-sm" style="padding:0;font-size:inherit"
|
||
data-action="goto-praxen">Praxis im Tab Praxen anlegen</button>
|
||
</div>
|
||
<label class="form-label" style="margin-top:var(--space-2)">Tierarzt / Praxis (Freitext)</label>
|
||
<input class="form-control" name="tierarzt_name"
|
||
value="${_esc(entry?.tierarzt_name || '')}" placeholder="Dr. Muster">
|
||
</div>`;
|
||
return `
|
||
${praxisField}
|
||
<div class="form-group">
|
||
<label class="form-label">Diagnose</label>
|
||
<textarea class="form-control" name="diagnose" rows="2">${_esc(entry?.diagnose || '')}</textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kosten (€)</label>
|
||
<input class="form-control" type="number" step="0.01" min="0" name="kosten"
|
||
value="${entry?.kosten ?? ''}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Nächster Termin (optional)</label>
|
||
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
||
</div>
|
||
`;
|
||
}
|
||
case 'gewicht': return `
|
||
<div class="form-group">
|
||
<label class="form-label">Gewicht (kg) *</label>
|
||
<input class="form-control" type="number" step="0.01" min="0" name="wert"
|
||
value="${entry?.wert ?? ''}" required>
|
||
</div>
|
||
`;
|
||
case 'medikament': return `
|
||
<div class="form-group">
|
||
<label class="form-label">Dosierung</label>
|
||
<input class="form-control" type="text" name="dosierung"
|
||
value="${_esc(entry?.dosierung || '')}" placeholder="z.B. 1 Tablette">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Häufigkeit</label>
|
||
<input class="form-control" type="text" name="haeufigkeit"
|
||
value="${_esc(entry?.haeufigkeit || '')}" placeholder="z.B. täglich, 2x wöchentlich">
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">Gabe bis (optional)</label>
|
||
<input class="form-control" type="date" name="bis_datum" value="${entry?.bis_datum || ''}">
|
||
</div>
|
||
${_intervallField(entry)}
|
||
</div>
|
||
${_praxisSelectField(entry)}
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||
<input type="checkbox" name="aktiv" ${entry?.aktiv !== 0 ? 'checked' : ''}>
|
||
Aktuell aktiv
|
||
</label>
|
||
</div>
|
||
`;
|
||
case 'allergie': return `
|
||
<div class="form-group">
|
||
<label class="form-label">Schweregrad</label>
|
||
<select class="form-control" name="schweregrad">
|
||
<option value="">— unbekannt —</option>
|
||
${['leicht', 'mittel', 'schwer'].map(s =>
|
||
`<option value="${s}" ${entry?.schweregrad === s ? 'selected' : ''}>${s}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Reaktion / Symptome</label>
|
||
<textarea class="form-control" name="reaktion" rows="2">${_esc(entry?.reaktion || '')}</textarea>
|
||
</div>
|
||
`;
|
||
case 'laeufigkeit': {
|
||
const prevCycles = (_data['laeufigkeit'] || []).filter(e => e !== entry && e?.datum);
|
||
const sorted = [...prevCycles].sort((a, b) => a.datum.localeCompare(b.datum));
|
||
const lastCycle = sorted[sorted.length - 1];
|
||
|
||
// Abstand zur letzten Läufigkeit (in Tagen)
|
||
let daysSinceLast = null;
|
||
if (lastCycle) {
|
||
daysSinceLast = Math.round((new Date() - new Date(lastCycle.datum)) / 86400000);
|
||
}
|
||
|
||
// Durchschnittlicher Zyklus aus ≥2 Einträgen, sonst gemessener Abstand
|
||
let avgInterval = 0;
|
||
if (sorted.length >= 2) {
|
||
const intervals = [];
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
intervals.push(Math.round((new Date(sorted[i].datum) - new Date(sorted[i-1].datum)) / 86400000));
|
||
}
|
||
avgInterval = Math.round(intervals.reduce((a, b) => a + b, 0) / intervals.length);
|
||
} else if (daysSinceLast !== null) {
|
||
avgInterval = daysSinceLast; // erster gemessener Abstand als Vorschlag
|
||
}
|
||
const defaultInterval = avgInterval || (entry?.intervall_tage) || 180;
|
||
|
||
const lastInfo = lastCycle ? `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3)">
|
||
Letzte Läufigkeit: <strong>${UI.time.format(lastCycle.datum + 'T00:00:00')}</strong>
|
||
— vor <strong>${daysSinceLast} Tagen</strong>
|
||
</div>` : '';
|
||
return `
|
||
${lastInfo}
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">Dauer (Tage)</label>
|
||
<input class="form-control" type="number" min="1" max="60" name="wert"
|
||
value="${entry?.wert ?? ''}" placeholder="z.B. 21"
|
||
id="laeufi-dauer">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Zyklusabstand (Tage)</label>
|
||
<input class="form-control" type="number" min="60" max="400" name="intervall_tage"
|
||
value="${entry?.intervall_tage || defaultInterval}"
|
||
id="laeufi-interval">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Nächste erwartet</label>
|
||
<input class="form-control" type="date" name="naechstes"
|
||
value="${entry?.naechstes || ''}" id="laeufi-naechstes">
|
||
</div>
|
||
<script>
|
||
(function() {
|
||
const datum = document.querySelector('[name="datum"]');
|
||
const interval = document.getElementById('laeufi-interval');
|
||
const naechstes = document.getElementById('laeufi-naechstes');
|
||
function updateNext() {
|
||
const d = datum?.value;
|
||
const iv = parseInt(interval?.value) || 0;
|
||
if (d && iv) {
|
||
const next = new Date(d);
|
||
next.setDate(next.getDate() + iv);
|
||
naechstes.value = next.toISOString().slice(0, 10);
|
||
}
|
||
}
|
||
datum?.addEventListener('change', updateNext);
|
||
interval?.addEventListener('change', updateNext);
|
||
if (!naechstes?.value) updateNext();
|
||
})();
|
||
</script>
|
||
`;
|
||
}
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
function _buildPayload(fd, typ) {
|
||
const p = {
|
||
bezeichnung: fd.bezeichnung || null,
|
||
datum: fd.datum || null,
|
||
notiz: fd.notiz || null,
|
||
naechstes: fd.naechstes || null,
|
||
tierarzt_name: fd.tierarzt_name || null,
|
||
charge_nr: fd.charge_nr || null,
|
||
diagnose: fd.diagnose || null,
|
||
dosierung: fd.dosierung || null,
|
||
haeufigkeit: fd.haeufigkeit || null,
|
||
bis_datum: fd.bis_datum || null,
|
||
schweregrad: fd.schweregrad || null,
|
||
reaktion: fd.reaktion || null,
|
||
};
|
||
if (fd.wert) {
|
||
p.wert = parseFloat(fd.wert.toString().replace(',', '.'));
|
||
if (typ === 'gewicht') p.bezeichnung = `${p.wert} kg`;
|
||
}
|
||
if (typ === 'laeufigkeit') {
|
||
p.bezeichnung = p.bezeichnung || 'Läufigkeit';
|
||
}
|
||
if (fd.kosten) p.kosten = parseFloat(fd.kosten.toString().replace(',', '.'));
|
||
if (fd.tierarzt_id) {
|
||
p.tierarzt_id = parseInt(fd.tierarzt_id);
|
||
// Praxisname auch als tierarzt_name sichern (bleibt lesbar wenn Praxis inaktiv/gelöscht)
|
||
const praxis = _praxen.find(x => x.id === p.tierarzt_id);
|
||
if (praxis) p.tierarzt_name = praxis.name;
|
||
}
|
||
if (typ === 'medikament') {
|
||
p.aktiv = 'aktiv' in fd ? 1 : 0;
|
||
}
|
||
p.intervall_tage = fd.intervall_tage ? parseInt(fd.intervall_tage) : null;
|
||
// Gewicht-Einheit
|
||
p.einheit = fd.einheit || 'kg';
|
||
return p;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// PRAXEN — Liste
|
||
// ----------------------------------------------------------
|
||
function _renderPraxen() {
|
||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-praxis">+ Praxis hinzufügen</button>`;
|
||
|
||
const aktive = _praxen.filter(p => p.aktiv);
|
||
const inaktive = _praxen.filter(p => !p.aktiv);
|
||
|
||
if (!_praxen.length) return UI.emptyState({
|
||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Praxis eingetragen',
|
||
text: 'Trage deine Tierarztpraxis ein für schnellen Zugriff.',
|
||
action: addBtn
|
||
});
|
||
|
||
const renderCard = p => `
|
||
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||
data-praxis-id="${p.id}" data-action="open-praxis">
|
||
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>'}</div>
|
||
<div class="health-card-body">
|
||
<div class="health-card-title">
|
||
${_esc(p.name)}
|
||
${!p.aktiv ? '<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:400"> · Ehemalig</span>' : ''}
|
||
</div>
|
||
${(p.strasse || p.plz || p.ort) ? `
|
||
<div class="health-card-meta">
|
||
${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')}
|
||
</div>` : ''}
|
||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
||
${p.telefon ? `
|
||
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
|
||
onclick="event.stopPropagation()">
|
||
📞 Anrufen
|
||
</a>` : ''}
|
||
${p.notfall_telefon ? `
|
||
<a href="tel:${_esc(p.notfall_telefon)}" class="btn btn-danger btn-sm"
|
||
onclick="event.stopPropagation()">
|
||
🚨 Notfall
|
||
</a>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
${addBtn}
|
||
</div>
|
||
<div class="health-list">
|
||
${aktive.map(renderCard).join('')}
|
||
${inaktive.length ? `
|
||
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
|
||
border-top:1px solid var(--c-border)">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||
margin:0 0 var(--space-3)">Ehemalige Praxen</p>
|
||
${inaktive.map(renderCard).join('')}
|
||
</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// PRAXEN — Formular (Neu / Bearbeiten)
|
||
// ----------------------------------------------------------
|
||
function _showPraxForm(praxis) {
|
||
const isEdit = !!praxis;
|
||
UI.modal.open({
|
||
title: isEdit ? `${praxis.name} bearbeiten` : 'Praxis hinzufügen',
|
||
body: `
|
||
<form id="praxis-form" autocomplete="off">
|
||
<div class="form-group">
|
||
<label class="form-label">Name der Praxis *</label>
|
||
<input class="form-control" type="text" name="name"
|
||
value="${_esc(praxis?.name || '')}" placeholder="Dr. Muster Tierarztpraxis" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Straße & Hausnummer</label>
|
||
<input class="form-control" type="text" name="strasse"
|
||
value="${_esc(praxis?.strasse || '')}" placeholder="Musterstraße 1">
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:120px 1fr;gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">PLZ</label>
|
||
<input class="form-control" type="text" name="plz" inputmode="numeric"
|
||
value="${_esc(praxis?.plz || '')}" placeholder="12345">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ort</label>
|
||
<input class="form-control" type="text" name="ort"
|
||
value="${_esc(praxis?.ort || '')}" placeholder="Musterstadt">
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">Telefon</label>
|
||
<input class="form-control" type="tel" name="telefon"
|
||
value="${_esc(praxis?.telefon || '')}" placeholder="089 123456">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Notfall-Telefon</label>
|
||
<input class="form-control" type="tel" name="notfall_telefon"
|
||
value="${_esc(praxis?.notfall_telefon || '')}" placeholder="089 999999">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">E-Mail</label>
|
||
<input class="form-control" type="email" name="email"
|
||
value="${_esc(praxis?.email || '')}" placeholder="praxis@beispiel.de">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Notizen</label>
|
||
<textarea class="form-control" name="notizen" rows="2"
|
||
placeholder="Öffnungszeiten, Besonderheiten…">${_esc(praxis?.notizen || '')}</textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||
<input type="checkbox" name="ist_notfallpraxis" ${praxis?.ist_notfallpraxis ? 'checked' : ''}>
|
||
Notfallpraxis (24h / Wochenende)
|
||
</label>
|
||
</div>
|
||
${isEdit ? `
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||
<input type="checkbox" name="inaktiv" ${!praxis?.aktiv ? 'checked' : ''}>
|
||
Als ehemalige Praxis markieren (bei Umzug / Arztwechsel)
|
||
</label>
|
||
</div>` : ''}
|
||
</form>
|
||
`,
|
||
footer: `
|
||
<button type="button" class="btn btn-secondary flex-1" id="praxis-cancel">Abbrechen</button>
|
||
<button type="submit" form="praxis-form" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
|
||
`,
|
||
});
|
||
|
||
document.getElementById('praxis-cancel')?.addEventListener('click', UI.modal.close);
|
||
|
||
document.getElementById('praxis-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = document.querySelector('[form="praxis-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
|
||
const fd = UI.formData(e.target);
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
const payload = {
|
||
name: fd.name?.trim(),
|
||
strasse: fd.strasse || null,
|
||
plz: fd.plz || null,
|
||
ort: fd.ort || null,
|
||
telefon: fd.telefon || null,
|
||
notfall_telefon: fd.notfall_telefon || null,
|
||
email: fd.email || null,
|
||
notizen: fd.notizen || null,
|
||
ist_notfallpraxis: 'ist_notfallpraxis' in fd,
|
||
};
|
||
if (!payload.name) { UI.toast.warning('Bitte einen Namen eingeben.'); return; }
|
||
|
||
let saved;
|
||
if (isEdit) {
|
||
payload.aktiv = !('inaktiv' in fd);
|
||
saved = await API.tieraerzte.update(praxis.id, payload);
|
||
_praxen = _praxen.map(p => p.id === praxis.id ? saved : p);
|
||
UI.toast.success('Praxis gespeichert.');
|
||
} else {
|
||
saved = await API.tieraerzte.create(payload);
|
||
_praxen.push(saved);
|
||
UI.toast.success(`${saved.name} hinzugefügt.`);
|
||
}
|
||
UI.modal.close();
|
||
_renderTab();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// SYMPTOM-CHECK
|
||
// ----------------------------------------------------------
|
||
function _renderSymptomCheck(content) {
|
||
content.innerHTML = `
|
||
<div style="padding:var(--space-4)">
|
||
<div class="card" style="padding:var(--space-4)">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
|
||
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Einschätzung — kein Ersatz für den Tierarzt.
|
||
</p>
|
||
<div class="form-group">
|
||
<label class="form-label" for="symptom-input">Symptome</label>
|
||
<textarea id="symptom-input" class="form-control" rows="4"
|
||
placeholder="z.B. frisst nicht, trinkt viel, schläft mehr als sonst..."></textarea>
|
||
</div>
|
||
<button id="symptom-submit-btn" class="btn btn-primary" style="width:100%">
|
||
Symptome analysieren <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||
</button>
|
||
<div id="symptom-result" style="display:none;margin-top:var(--space-5)"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
content.querySelector('#symptom-submit-btn').addEventListener('click', async function () {
|
||
const btn = this;
|
||
const textarea = content.querySelector('#symptom-input');
|
||
const resultEl = content.querySelector('#symptom-result');
|
||
const symptoms = textarea.value.trim();
|
||
|
||
if (!symptoms) {
|
||
UI.toast.warning('Bitte Symptome eingeben.');
|
||
return;
|
||
}
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
resultEl.style.display = 'none';
|
||
resultEl.innerHTML = '';
|
||
|
||
let result;
|
||
try {
|
||
result = await API.post(
|
||
`/dogs/${_appState.activeDog.id}/health/symptom-check`,
|
||
{ symptoms }
|
||
);
|
||
} catch (err) {
|
||
if (err.status === 402) {
|
||
resultEl.innerHTML = `
|
||
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning)">
|
||
<p style="margin:0;font-size:var(--text-sm)">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
|
||
</p>
|
||
</div>`;
|
||
} else if (err.status === 503) {
|
||
resultEl.innerHTML = `
|
||
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger)">
|
||
<p style="margin:0;font-size:var(--text-sm)">
|
||
KI-Server nicht erreichbar. Bitte später versuchen.
|
||
</p>
|
||
</div>`;
|
||
} else {
|
||
UI.toast.error(err.message || 'Fehler bei der Symptomanalyse.');
|
||
return;
|
||
}
|
||
resultEl.style.display = '';
|
||
return;
|
||
}
|
||
|
||
const DRINGLICHKEIT = {
|
||
beobachten: { badgeClass: 'badge-success', label: '🟢 Beobachten' },
|
||
tierarzt: { badgeClass: 'badge-warning', label: '🟡 Zum Tierarzt' },
|
||
notfall: { badgeClass: 'badge-danger', label: '🔴 Notfall — sofort zum Tierarzt!' },
|
||
};
|
||
const d = DRINGLICHKEIT[result.dringlichkeit] || { badgeClass: 'badge-primary', label: _esc(result.dringlichkeit) };
|
||
|
||
const hinweiseHtml = (result.hinweise || []).length
|
||
? `<ul style="margin:var(--space-2) 0 0;padding-left:var(--space-5);font-size:var(--text-sm)">
|
||
${result.hinweise.map(h => `<li style="margin-bottom:var(--space-1)">${_esc(h)}</li>`).join('')}
|
||
</ul>`
|
||
: '';
|
||
|
||
const zumTierarztHtml = result.zum_tierarzt_wenn
|
||
? `<div style="margin-top:var(--space-3);padding:var(--space-3);
|
||
background:var(--c-surface-alt,var(--c-surface));
|
||
border-radius:var(--radius-md);font-size:var(--text-sm)">
|
||
<strong>Zum Tierarzt wenn:</strong> ${_esc(result.zum_tierarzt_wenn)}
|
||
</div>`
|
||
: '';
|
||
|
||
resultEl.innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||
<span class="badge ${d.badgeClass}" style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3)">
|
||
${d.label}
|
||
</span>
|
||
</div>
|
||
${result.einschaetzung
|
||
? `<p style="font-size:var(--text-sm);line-height:1.6;margin:0">${_esc(result.einschaetzung)}</p>`
|
||
: ''}
|
||
${hinweiseHtml}
|
||
${zumTierarztHtml}
|
||
`;
|
||
resultEl.style.display = '';
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// TRANSPONDER-BEARBEITUNG
|
||
// ----------------------------------------------------------
|
||
async function _editTransponder(dog) {
|
||
const currentNr = dog?.chip_nr || '';
|
||
UI.modal.open({
|
||
title: 'Transpondernummer',
|
||
body: `
|
||
<div class="mb-3">
|
||
<label class="form-label">Chip-Nummer (15-stellig)</label>
|
||
<input id="transponder-input" class="form-control" type="text"
|
||
value="${_esc(currentNr)}" placeholder="z.B. 276009200123456" maxlength="20">
|
||
</div>`,
|
||
footer: `
|
||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||
<button class="btn btn-primary" id="transponder-save-btn">Speichern</button>`,
|
||
});
|
||
document.getElementById('transponder-save-btn').addEventListener('click', async () => {
|
||
const nr = document.getElementById('transponder-input').value.trim() || null;
|
||
const btn = document.getElementById('transponder-save-btn');
|
||
UI.setLoading(btn, true);
|
||
try {
|
||
await API.dogs.update(dog.id, { chip_nr: nr });
|
||
_appState.activeDog.chip_nr = nr;
|
||
UI.modal.close();
|
||
const nrEl = _container.querySelector('#health-transponder-nr');
|
||
if (nrEl) nrEl.innerHTML = nr
|
||
? `<strong>${_esc(nr)}</strong>`
|
||
: '<em style="color:var(--c-text-muted)">nicht eingetragen</em>';
|
||
} catch (e) {
|
||
UI.setLoading(btn, false);
|
||
UI.toast('Fehler beim Speichern', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// KI-ZUSAMMENFASSUNG
|
||
// ----------------------------------------------------------
|
||
async function _showKiSummary() {
|
||
const btn = _container.querySelector('#health-ki-btn');
|
||
UI.setLoading(btn, true);
|
||
|
||
try {
|
||
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
|
||
UI.modal.open({
|
||
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
|
||
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
|
||
});
|
||
} catch (err) {
|
||
if (err.status === 503) {
|
||
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
|
||
} else if (err.status === 402) {
|
||
UI.toast.warning('Diese Funktion ist Teil von Ban Yaro Premium.');
|
||
} else {
|
||
UI.toast.error(err.message || 'Fehler bei der KI-Zusammenfassung.');
|
||
}
|
||
} finally {
|
||
UI.setLoading(btn, false);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HELPER
|
||
// ----------------------------------------------------------
|
||
function _esc(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
|
||
// ----------------------------------------------------------
|
||
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||
// Vorhandenes Modal entfernen falls noch offen
|
||
document.getElementById('by-note-modal')?.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'by-note-modal';
|
||
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
|
||
|
||
overlay.innerHTML = `
|
||
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
|
||
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
|
||
padding-bottom:env(safe-area-inset-bottom,0px)">
|
||
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
|
||
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
||
<div>
|
||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
|
||
</div>
|
||
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
|
||
</div>
|
||
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
|
||
<form id="by-note-form">
|
||
<textarea id="by-note-text" class="form-control" rows="5"
|
||
placeholder="Notiz eingeben…"
|
||
style="width:100%;resize:vertical"></textarea>
|
||
</form>
|
||
</div>
|
||
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
|
||
display:flex;gap:var(--space-2);flex-shrink:0">
|
||
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
|
||
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const textarea = document.getElementById('by-note-text');
|
||
const saveBtn = document.getElementById('by-note-save');
|
||
const cancelBtn = document.getElementById('by-note-cancel');
|
||
const closeBtn = document.getElementById('by-note-close');
|
||
|
||
let existingNoteId = null;
|
||
|
||
// Vorhandene Notiz laden
|
||
try {
|
||
const existing = await API.notes.get(parentType, parentId);
|
||
if (existing?.id) {
|
||
existingNoteId = existing.id;
|
||
textarea.value = existing.text || '';
|
||
}
|
||
} catch (_) { /* keine Notiz vorhanden — ok */ }
|
||
|
||
setTimeout(() => textarea.focus(), 100);
|
||
|
||
const _close = () => overlay.remove();
|
||
closeBtn.addEventListener('click', _close);
|
||
cancelBtn.addEventListener('click', _close);
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
||
|
||
document.getElementById('by-note-form').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const text = textarea.value.trim();
|
||
UI.setLoading(saveBtn, true);
|
||
try {
|
||
const payload = { text, parent_label: parentLabel, location_name: locationName };
|
||
if (existingNoteId) {
|
||
await API.notes.update(existingNoteId, payload);
|
||
} else {
|
||
await API.notes.create(parentType, parentId, payload);
|
||
}
|
||
UI.toast.success('Notiz gespeichert.');
|
||
_close();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler beim Speichern.');
|
||
UI.setLoading(saveBtn, false);
|
||
}
|
||
});
|
||
}
|
||
|
||
return { init, refresh, openNew, onDogChange };
|
||
|
||
})();
|