Frontend Sprint 3+4: Dog-Switcher, Health-Seite, Multi-Dog Tagebuch

- app.js: vollständiger Dog-Switcher (Avatar im Header/Sidebar, Quickpicker
  bei 3+ Hunden, setActiveDog, localStorage-Persistenz), iOS Ghost-Click Fix,
  Loading-Guard, Logout State Reset
- index.html: Dog-Switcher HTML, Favicon-Links, Sidebar "+ Neu erstellen",
  Navigation Tab Karte → Gesundheit
- health.js (neu): vollständiges Health-Frontend mit Tabs (Impfung, Entwurmung,
  Tierarzt, Medikament, Gewicht-Kurve, Allergie, Dokument), Ampel-System,
  KI-Zusammenfassung
- dog-profile.js: "+ Weiteren Hund anlegen" Button + _openCreateModal(),
  Event-Delegation statt direkter Listener (kein Doppelaufruf)
- diary.js: Dog-Picker im Formular, Avatar-Reihe auf Karten, Dog-Chips
  im Detail-Modal, dog_ids im API-Payload
- poison.js: Erledigt-Dialog mit Grundauswahl (beseitigt/fehlerhaft/anderes)
- api.js: health-Endpoints (list, create, update, delete, upload, ki)
- ui.js: confirm() Fix (resolve vor close)
- layout.css: Dog-Switcher Styles, scrollbare Sidebar-Nav, User-Item fix
- components.css: Health-Styles, Diary Dog-Picker, Ampel-Punkte, Gewicht-SVG
- icons/: Favicon-Set (ico, 16px, 32px, 180px, 192px, 512px)
This commit is contained in:
rene 2026-04-13 19:30:03 +02:00
parent 6f48ec581d
commit d8b9561fff
16 changed files with 1597 additions and 91 deletions

View file

@ -441,6 +441,7 @@ textarea.form-control {
padding: var(--space-4);
backdrop-filter: blur(2px);
animation: overlay-in var(--transition-normal) ease;
touch-action: manipulation;
}
@media (min-width: 768px) {
.modal-overlay { align-items: center; }
@ -812,3 +813,409 @@ textarea.form-control {
/* Leaflet-Attribution ausblenden */
.leaflet-control-attribution { display: none !important; }
/* ============================================================
GESUNDHEIT
============================================================ */
/* Header mit KI-Button */
.health-header {
display: flex;
justify-content: flex-end;
padding: var(--space-3) 0 var(--space-2);
}
/* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */
.health-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-1);
padding-bottom: var(--space-2);
margin-bottom: var(--space-3);
}
/* Auf sehr kleinen Screens: scrollen statt umbrechen */
@media (max-width: 480px) {
.health-tabs {
flex-wrap: nowrap;
overflow-x: auto;
padding-right: var(--space-4);
scrollbar-width: none;
}
.health-tabs::-webkit-scrollbar { display: none; }
}
.health-tab {
flex-shrink: 0;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--c-border);
border-radius: var(--radius-full);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
touch-action: manipulation;
}
.health-tab.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: var(--c-text-inverse);
}
/* Karten-Liste */
.health-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Einzelne Karte */
.health-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: var(--space-4);
cursor: pointer;
display: flex;
gap: var(--space-3);
align-items: flex-start;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.health-card:active { transform: scale(0.985); }
.health-card--inactive { opacity: 0.55; }
.health-card-body { flex: 1; min-width: 0; }
.health-card-title { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); }
.health-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); }
.health-card-next { font-size: var(--text-sm); font-weight: var(--weight-medium); margin-top: var(--space-1); }
.health-card-note { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); }
/* Ampel-Punkt (links an der Karte) */
.health-card-ampel {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.ampel-green { background: #22c55e; }
.ampel-yellow { background: #f59e0b; }
.ampel-red { background: #ef4444; }
.ampel-grey { background: var(--c-border); }
.ampel-text-green { color: #16a34a; }
.ampel-text-yellow { color: #d97706; }
.ampel-text-red { color: #dc2626; }
/* Gruppen-Label (z.B. "Aktuelle Medikamente") */
.health-group-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: var(--space-3) 0 var(--space-1);
}
/* Gewicht-Diagramm-Wrapper */
.health-chart-wrap {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
overflow: hidden;
}
/* Dokument-Thumbnail und Icon */
.health-doc-thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.health-doc-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: var(--c-surface-2);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
/* Detail-Dialog DL */
.health-detail-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-1) var(--space-4);
font-size: var(--text-sm);
}
.health-detail-dl dt {
color: var(--c-text-secondary);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.health-detail-dl dd { margin: 0; }
/* ------------------------------------------------------------
DOG SWITCHER
Avatar-Leiste in Header (Mobile) + Sidebar-Logo (Desktop)
------------------------------------------------------------ */
/* Aktiver (linker) Hund — primärer Ring */
.dog-sw-active {
width: 34px;
height: 34px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
flex-shrink: 0;
cursor: pointer;
border: 2.5px solid var(--c-primary);
transition: transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.dog-sw-active:hover {
transform: scale(1.06);
box-shadow: 0 0 0 3px var(--c-primary-subtle);
}
.dog-sw-active img { width:100%; height:100%; object-fit:cover; display:block; }
.dog-sw-active span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 17px;
}
/* "Ban Yaro" Label — füllt den Zwischenraum */
.dog-sw-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Gruppe der inaktiven Hunde (rechts) */
.dog-sw-others {
display: flex;
align-items: center;
position: relative; /* Anker für Quickpicker-Dropdown */
flex-shrink: 0;
}
/* Inaktiver Hund-Avatar */
.dog-sw-other {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
border: 2px solid var(--c-surface);
cursor: pointer;
flex-shrink: 0;
transition: transform var(--transition-fast);
position: relative;
}
.dog-sw-other:hover { transform: scale(1.12); }
.dog-sw-other img { width:100%; height:100%; object-fit:cover; display:block; }
.dog-sw-other span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 14px;
}
/* Gestapelter Avatar-Stack (3+ Hunde) */
.dog-sw-stack {
display: flex;
align-items: center;
cursor: pointer;
}
.dog-sw-stack .dog-sw-other { margin-left: -9px; }
.dog-sw-stack .dog-sw-other:first-child { margin-left: 0; }
.dog-sw-stack .dog-sw-other--0 { z-index: 3; }
.dog-sw-stack .dog-sw-other--1 { z-index: 2; }
.dog-sw-stack .dog-sw-other--2 { z-index: 1; }
.dog-sw-stack:hover .dog-sw-other { filter: brightness(1.05); }
/* +N Überlauf-Badge */
.dog-sw-more {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
background: var(--c-surface-2);
border: 2px solid var(--c-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: var(--weight-bold);
color: var(--c-text-secondary);
margin-left: -9px;
position: relative;
z-index: 0;
}
/* Quickpicker-Dropdown */
.dog-quickpick {
position: absolute;
top: calc(100% + 10px);
right: 0;
background: var(--c-surface);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: var(--space-2);
min-width: 170px;
z-index: 600;
}
.dog-quickpick.hidden { display: none; }
.dog-qp-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text);
transition: background var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.dog-qp-item:hover { background: var(--c-bg); }
.dog-qp-av {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
flex-shrink: 0;
}
.dog-qp-av img { width:100%; height:100%; object-fit:cover; display:block; }
.dog-qp-av span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 16px;
}
/* Sidebar: größerer aktiver Avatar */
#sidebar-dog-switcher .dog-sw-active {
width: 36px;
height: 36px;
}
/* ------------------------------------------------------------
DIARY Multi-Dog (Hunde-Auswahl im Formular + Karten-Anzeige)
------------------------------------------------------------ */
/* Avatar-Reihe in der Tagebuch-Karte */
.diary-dog-row {
display: flex;
align-items: center;
gap: -4px; /* überlappend via margin */
margin-top: var(--space-2);
flex-wrap: wrap;
gap: var(--space-1);
}
.diary-dog-av {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
border: 1.5px solid var(--c-surface);
flex-shrink: 0;
}
.diary-dog-av img { width:100%; height:100%; object-fit:cover; display:block; }
.diary-dog-av span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 12px;
}
/* Hunde-Chip in der Detail-Ansicht */
.diary-detail-dogs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.diary-dog-chip {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px 3px 4px;
background: var(--c-surface-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--c-text-secondary);
}
.diary-dog-chip .diary-dog-av {
width: 20px;
height: 20px;
}
/* Hunde-Picker im Formular */
.diary-dog-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.diary-dog-pick-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--c-border-light);
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text-secondary);
transition: border-color var(--transition-fast),
background var(--transition-fast),
color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.diary-dog-pick-item input { display: none; }
.diary-dog-pick-item .diary-dog-av {
width: 26px;
height: 26px;
}
.diary-dog-pick-item:hover {
border-color: var(--c-primary);
color: var(--c-text);
}
.diary-dog-pick-item.checked {
border-color: var(--c-primary);
background: var(--c-primary-subtle);
color: var(--c-primary-dark);
font-weight: var(--weight-semibold);
}

View file

@ -58,6 +58,16 @@
flex: 1;
}
/* Dog Switcher Container im Header */
#header-dog-switcher {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
overflow: visible;
}
.header-back {
display: flex;
align-items: center;
@ -201,7 +211,7 @@
background: var(--c-surface);
border-right: 1px solid var(--c-border-light);
flex-direction: column;
overflow-y: auto;
overflow: hidden; /* Sidebar selbst scrollt nicht */
box-shadow: var(--shadow-sm);
}
@ -229,14 +239,31 @@
color: var(--c-text);
}
.sidebar-nav {
flex: 1;
padding: var(--space-4) var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-1);
.sidebar-add {
padding: var(--space-4) var(--space-4) var(--space-2);
flex-shrink: 0;
}
.sidebar-nav {
flex: 1;
padding: var(--space-2) var(--space-2) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
min-height: 0; /* wichtig: flex-child darf kleiner werden als Inhalt */
/* Firefox */
scrollbar-width: thin;
scrollbar-color: var(--c-primary) var(--c-surface);
}
.sidebar-nav::-webkit-scrollbar { width: 6px; }
.sidebar-nav::-webkit-scrollbar-track { background: var(--c-surface); }
.sidebar-nav::-webkit-scrollbar-thumb {
background: var(--c-primary);
border-radius: 3px;
}
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: var(--c-primary-dark); }
.sidebar-section-label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
@ -270,6 +297,12 @@
color: var(--c-primary-dark);
font-weight: var(--weight-semibold);
}
/* User-Eintrag bekommt nie den Active-Stil */
.sidebar-item--user.active {
background: transparent;
color: var(--c-text-secondary);
font-weight: var(--weight-medium);
}
.sidebar-item-icon {
font-size: 18px;
width: 24px;
@ -291,11 +324,7 @@
padding: 0 var(--space-1);
}
.sidebar-footer {
padding: var(--space-4) var(--space-2);
border-top: 1px solid var(--c-border-light);
flex-shrink: 0;
}
/* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */
/* ------------------------------------------------------------
5. PAGE WRAPPER (inneres Layout der Seiten)

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

View file

@ -6,9 +6,14 @@
<meta name="theme-color" content="#C4843A">
<meta name="description" content="Ban Yaro — Die Hunde-Plattform. Alles rund um deinen Hund.">
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png">
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon-180.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ban Yaro">
@ -30,17 +35,23 @@
<!-- MOBILE HEADER (wird per JS mit Seitentitel befüllt) -->
<header id="app-header">
<button class="header-back hidden" id="header-back" aria-label="Zurück">&#8592;</button>
<span class="header-title" id="header-title">Ban Yaro</span>
<div id="header-dog-switcher" class="dog-switcher">
<span class="header-title" id="header-title">Ban Yaro</span>
</div>
<div id="header-actions"></div>
</header>
<!-- DESKTOP SIDEBAR -->
<nav id="sidebar" role="navigation" aria-label="Hauptnavigation">
<div class="sidebar-logo">
<div class="sidebar-logo" id="sidebar-dog-switcher">
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>
</div>
<div class="sidebar-add">
<button class="btn btn-primary btn-full" id="sidebar-add">+ Neu erstellen</button>
</div>
<div class="sidebar-nav">
<span class="sidebar-section-label">Mein Hund</span>
<div class="sidebar-item active" data-page="diary">
@ -92,13 +103,8 @@
<div class="sidebar-item" data-page="movies">
<span class="sidebar-item-icon">🎬</span> Filme
</div>
</div>
<div class="sidebar-footer">
<div class="sidebar-item" data-page="settings">
<span class="sidebar-item-icon">⚙️</span> Einstellungen
</div>
<div class="sidebar-item" id="sidebar-user">
<div class="sidebar-item sidebar-item--user" id="sidebar-user">
<span class="sidebar-item-icon">👤</span>
<span id="sidebar-username">Anmelden</span>
</div>
@ -179,9 +185,9 @@
<span class="nav-item-icon">📖</span>
<span class="nav-item-label">Tagebuch</span>
</div>
<div class="nav-item" data-page="map">
<span class="nav-item-icon">🗺️</span>
<span class="nav-item-label">Karte</span>
<div class="nav-item" data-page="health">
<span class="nav-item-icon">💉</span>
<span class="nav-item-label">Gesundheit</span>
</div>
<!-- Mittlerer + Button -->
<div class="nav-item nav-item-center" id="nav-add">

View file

@ -116,10 +116,19 @@ const API = (() => {
// GESUNDHEIT
// ----------------------------------------------------------
const health = {
list(dogId) { return get(`/dogs/${dogId}/health`); },
list(dogId, typ = null) {
const q = typ ? `?typ=${encodeURIComponent(typ)}` : '';
return get(`/dogs/${dogId}/health${q}`);
},
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
uploadDokument(dogId, id, formData) {
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
},
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},
@ -134,7 +143,7 @@ const API = (() => {
},
report(data) { return post('/poison', data); },
confirm(id) { return post(`/poison/${id}/confirm`); },
resolve(id) { return post(`/poison/${id}/resolve`); },
resolve(id, data={}) { return post(`/poison/${id}/resolve`, data); },
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
};

View file

@ -56,8 +56,9 @@ const App = (() => {
document.querySelectorAll(`[data-page="${pageId}"]`)
.forEach(el => el.classList.add('active'));
// Header-Titel setzen
document.getElementById('header-title').textContent = pages[pageId].title;
// Header-Titel setzen (nur wenn kein Dog-Switcher aktiv ist)
const titleEl = document.getElementById('header-title');
if (titleEl) titleEl.textContent = pages[pageId].title;
// History
if (pushHistory) {
@ -79,8 +80,12 @@ const App = (() => {
return;
}
// Guard: verhindert doppelten Load bei gleichzeitigen navigate()-Aufrufen
if (page._loading) return;
page._loading = true;
const container = document.querySelector(`#page-${pageId} .page-body`);
if (!container) return;
if (!container) { page._loading = false; return; }
// Skeleton während Laden
container.innerHTML = UI.skeleton(4);
@ -108,6 +113,8 @@ const App = (() => {
text: 'Diese Seite ist noch in Entwicklung.',
});
page.module = {};
} finally {
page._loading = false;
}
}
@ -134,8 +141,14 @@ const App = (() => {
return;
}
// + Button
if (e.target.closest('#nav-add')) {
// Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
if (e.target.closest('#sidebar-user')) {
navigate('settings');
return;
}
// + Button (Mobile Bottom-Nav + Desktop Sidebar)
if (e.target.closest('#nav-add') || e.target.closest('#sidebar-add')) {
_showQuickAdd();
}
});
@ -146,11 +159,8 @@ const App = (() => {
navigate(page, false);
});
// Initial: URL-Hash auslesen
const hash = location.hash.replace('#', '');
if (hash && pages[hash]) {
navigate(hash, false);
}
// Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier),
// damit kein doppelter _loadPage()-Aufruf entsteht.
}
// ----------------------------------------------------------
@ -184,13 +194,18 @@ const App = (() => {
document.querySelector('#modal-container').addEventListener('click', e => {
const btn = e.target.closest('[data-quick]');
if (!btn) return;
UI.modal.close();
const action = btn.dataset.quick;
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
UI.modal.close();
// Kurzes Delay wegen iOS Ghost-Click: nach modal.close() feuert iOS
// ~300ms später ein synthetisches Click-Event an derselben Position.
// Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort.
setTimeout(() => {
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
}, 350);
}, { once: true });
}
@ -201,19 +216,22 @@ const App = (() => {
try {
const user = await API.auth.me();
state.user = user;
_onLoggedIn();
await _onLoggedIn();
} catch {
_onLoggedOut();
}
}
function _onLoggedIn() {
async function _onLoggedIn() {
document.getElementById('sidebar-username').textContent = state.user.name;
_loadDogs();
await _loadDogs();
}
function _onLoggedOut() {
state.user = null;
state.user = null;
state.dogs = [];
state.activeDog = null;
_renderDogSwitcher();
navigate('settings', false);
}
@ -221,18 +239,142 @@ const App = (() => {
try {
state.dogs = await API.dogs.list();
if (state.dogs.length > 0) {
state.activeDog = state.dogs[0];
// Zuletzt aktiven Hund aus localStorage wiederherstellen
const savedId = parseInt(localStorage.getItem('by_active_dog') || '0');
state.activeDog = state.dogs.find(d => d.id === savedId) || state.dogs[0];
}
// Seitenmodule über neuen Hund informieren
_renderDogSwitcher();
_notifyDogChange();
} catch { /* kein Hund vorhanden */ }
}
function _notifyDogChange() {
// Alle geladenen Seiten-Module über Hundwechsel informieren
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
}
// ----------------------------------------------------------
// HUNDE-SWITCHER (Header Mobile + Sidebar-Logo Desktop)
// ----------------------------------------------------------
function _renderDogSwitcher() {
_renderSwitcherInto(document.getElementById('header-dog-switcher'), 'hdr');
_renderSwitcherInto(document.getElementById('sidebar-dog-switcher'), 'sb');
}
function _renderSwitcherInto(el, ctxId) {
if (!el) return;
const dog = state.activeDog;
const others = state.dogs.filter(d => d.id !== dog?.id);
// Fallback: kein User oder kein Hund → Standardlogo
if (!state.user || !dog) {
if (ctxId === 'sb') {
el.innerHTML = `
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>`;
} else {
el.innerHTML = `<span class="header-title" id="header-title">Ban Yaro</span>`;
}
return;
}
const avHtml = d => d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}">`
: `<span>🐕</span>`;
// Inaktive Hunde rechts
let othersHtml = '';
if (others.length === 1) {
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-other" data-dog-id="${others[0].id}" title="${_esc(others[0].name)}">
${avHtml(others[0])}
</div>
</div>`;
} else if (others.length >= 2) {
const visible = others.slice(0, 3);
const extraCount = others.length - 3;
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-stack" id="dog-sw-stack-${ctxId}">
${visible.map((d, i) => `
<div class="dog-sw-other dog-sw-other--${i}" data-dog-id="${d.id}" title="${_esc(d.name)}">
${avHtml(d)}
</div>`).join('')}
${extraCount > 0 ? `<div class="dog-sw-more">+${extraCount}</div>` : ''}
</div>
<div class="dog-quickpick hidden" id="dog-qp-${ctxId}">
${others.map(d => `
<div class="dog-qp-item" data-dog-id="${d.id}">
<div class="dog-qp-av">${avHtml(d)}</div>
<span>${_esc(d.name)}</span>
</div>`).join('')}
</div>
</div>`;
}
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
el.innerHTML = `
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${_esc(dog.name)} bearbeiten">
${avHtml(dog)}
</div>
<span class="${titleClass} dog-sw-title">Ban Yaro</span>
${othersHtml}`;
// Klick aktiver Avatar → Hund-Profil
el.querySelector(`#dog-sw-active-${ctxId}`)?.addEventListener('click', () => {
navigate('dog-profile');
});
// 1 anderer Hund → direkter Tausch
if (others.length === 1) {
el.querySelector('.dog-sw-other')?.addEventListener('click', () => {
setActiveDog(others[0].id);
});
}
// 2+ andere Hunde → Stack klick öffnet Quickpicker
if (others.length >= 2) {
const stack = el.querySelector(`#dog-sw-stack-${ctxId}`);
const qp = el.querySelector(`#dog-qp-${ctxId}`);
stack?.addEventListener('click', e => {
e.stopPropagation();
// Alle anderen Quickpicker schließen
document.querySelectorAll('.dog-quickpick').forEach(q => {
if (q !== qp) q.classList.add('hidden');
});
qp?.classList.toggle('hidden');
});
el.querySelectorAll('.dog-qp-item').forEach(item => {
item.addEventListener('click', e => {
e.stopPropagation();
setActiveDog(parseInt(item.dataset.dogId));
qp?.classList.add('hidden');
});
});
}
}
// Quickpicker schließen bei Klick außerhalb
document.addEventListener('click', () => {
document.querySelectorAll('.dog-quickpick').forEach(q => q.classList.add('hidden'));
});
function setActiveDog(dogId) {
const dog = state.dogs.find(d => d.id === dogId);
if (!dog || dog.id === state.activeDog?.id) return;
state.activeDog = dog;
localStorage.setItem('by_active_dog', String(dogId));
_renderDogSwitcher();
_notifyDogChange();
}
function _esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// INITIALISIERUNG
// ----------------------------------------------------------
@ -240,16 +382,19 @@ const App = (() => {
_bindNavigation();
await _checkAuth();
// Erste Seite laden (Hash oder Standard: diary)
const startPage = location.hash.replace('#', '') || 'diary';
navigate(pages[startPage] ? startPage : 'diary', false);
// Erste Seite laden: Hash aus URL oder Standard 'diary'.
// Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird
// (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf).
const hash = location.hash.replace('#', '');
const startPage = (hash && pages[hash]) ? hash : 'diary';
navigate(startPage, false);
}
// ----------------------------------------------------------
// ÖFFENTLICHE API
// (andere Module können App.state, App.navigate etc. nutzen)
// ----------------------------------------------------------
return { init, navigate, state };
return { init, navigate, state, renderDogSwitcher: _renderDogSwitcher };
})();

View file

@ -38,6 +38,11 @@ window.Page_diary = (() => {
// ----------------------------------------------------------
async function refresh() {
if (!_appState.activeDog) return;
// Wenn vorher "kein Hund"-Zustand: #diary-list existiert nicht → voll neu rendern
if (!_container.querySelector('#diary-list')) {
await _render();
return;
}
_offset = 0;
_entries = [];
await _load();
@ -185,6 +190,9 @@ window.Page_diary = (() => {
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
// Mehrere Hunde: kleine Avatare in der Karte
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
return `
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo}
@ -196,11 +204,24 @@ window.Page_diary = (() => {
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
${textPreview}
${tagsHtml}
${dogAvatars}
</div>
</div>
`;
}
function _dogAvatarRow(dogIds) {
if (!dogIds || dogIds.length <= 1) return '';
const avatars = dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// ----------------------------------------------------------
@ -217,6 +238,22 @@ window.Page_diary = (() => {
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: '';
// Hunde-Anzeige wenn mehrere beteiligt
const dogIds = entry.dog_ids || [entry.dog_id];
const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs">
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
</div>
<span>${_escape(dog.name)}</span>
</div>` : '';
}).join('')}
</div>`
: '';
const body = `
${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''}
${photo}
@ -226,6 +263,7 @@ window.Page_diary = (() => {
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span>
</div>
${dogsHtml}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>`
: ''}
@ -263,13 +301,33 @@ window.Page_diary = (() => {
// FORMULAR — Neu erstellen / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
const isEdit = !!entry;
const today = new Date().toISOString().slice(0, 10);
const typOpts = Object.entries(TYPEN)
const isEdit = !!entry;
const today = new Date().toISOString().slice(0, 10);
const activeDog = _appState.activeDog;
const typOpts = Object.entries(TYPEN)
.map(([val, { icon, label }]) =>
`<option value="${val}" ${entry?.typ === val ? 'selected' : ''}>${icon} ${label}</option>`)
.join('');
// Weitere Hunde: alle außer dem aktiven
const otherDogs = _appState.dogs.filter(d => d.id !== activeDog?.id);
const entryDogIds = entry?.dog_ids || [activeDog?.id];
const dogPickerHtml = otherDogs.length > 0 ? `
<div class="form-group">
<label class="form-label">Betrifft auch</label>
<div class="diary-dog-picker">
${otherDogs.map(d => `
<label class="diary-dog-pick-item ${entryDogIds.includes(d.id) ? 'checked' : ''}">
<input type="checkbox" name="extra_dog" value="${d.id}"
${entryDogIds.includes(d.id) ? 'checked' : ''}>
<div class="diary-dog-av">
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : '<span>🐕</span>'}
</div>
<span>${_escape(d.name)}</span>
</label>`).join('')}
</div>
</div>` : '';
const body = `
<form id="diary-form" autocomplete="off">
<div class="form-group">
@ -291,6 +349,7 @@ window.Page_diary = (() => {
<textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
</div>
${dogPickerHtml}
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_milestone" ${entry?.is_milestone ? 'checked' : ''}>
@ -318,6 +377,9 @@ window.Page_diary = (() => {
const form = document.getElementById('diary-form');
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// Foto-Vorschau
const photoInput = form.querySelector('[name="photo"]');
const photoPreview = document.getElementById('diary-photo-preview');
@ -330,11 +392,25 @@ window.Page_diary = (() => {
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Checked-Klasse auf Dog-Picker-Items toggeln
form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.diary-dog-pick-item').classList.toggle('checked', cb.checked);
});
});
form.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = form.querySelector('[type="submit"]');
const fd = UI.formData(form);
// dog_ids zusammenbauen: aktiver Hund + gewählte weitere
const dogIds = [_appState.activeDog.id];
form.querySelectorAll('.diary-dog-pick-item input:checked').forEach(cb => {
const id = parseInt(cb.value);
if (!dogIds.includes(id)) dogIds.push(id);
});
await UI.asyncButton(submitBtn, async () => {
const payload = {
datum: fd.datum || null,
@ -342,6 +418,7 @@ window.Page_diary = (() => {
titel: fd.titel || null,
text: fd.text || null,
is_milestone: 'is_milestone' in fd,
dog_ids: dogIds,
};
if (isEdit) {

View file

@ -14,6 +14,22 @@ window.Page_dog_profile = (() => {
async function init(container, appState) {
_container = container;
_appState = appState;
// Event-Delegation auf dem persistenten Container — überlebt innerHTML-Ersatz
_container.addEventListener('click', e => {
if (e.target.closest('#dp-add-dog-btn')) {
_openCreateModal();
return;
}
if (e.target.closest('#dp-edit-btn')) {
if (_appState.activeDog) _openEditModal(_appState.activeDog);
return;
}
if (e.target.closest('#profile-goto-login')) {
App.navigate('settings');
}
});
await _render();
}
@ -77,7 +93,7 @@ window.Page_dog_profile = (() => {
title="Foto ändern">
📷
<input type="file" id="dp-photo-input" accept="image/*"
capture="user" style="display:none">
style="display:none">
</label>
</div>
@ -135,9 +151,14 @@ window.Page_dog_profile = (() => {
</div>
` : ''}
<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
</button>
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
</button>
<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>
</div>
</div>
`;
@ -150,7 +171,6 @@ window.Page_dog_profile = (() => {
const fd = new FormData();
fd.append('file', file);
const result = await API.dogs.uploadPhoto(dog.id, fd);
// State in-place aktualisieren
dog.foto_url = result.foto_url;
_appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url };
_appState.dogs = _appState.dogs.map(d =>
@ -162,11 +182,7 @@ window.Page_dog_profile = (() => {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
// Bearbeiten öffnen
document.getElementById('dp-edit-btn')?.addEventListener('click', () => {
_openEditModal(dog);
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
// ----------------------------------------------------------
@ -190,18 +206,26 @@ window.Page_dog_profile = (() => {
_bindForm(null, false);
}
// ----------------------------------------------------------
// NEUEN HUND ANLEGEN (Modal) — auch aufrufbar via addNew()
// ----------------------------------------------------------
function _openCreateModal() {
UI.modal.open({ title: 'Weiteren Hund anlegen', body: _formHTML(null, true) });
_bindForm(null, true);
}
// ----------------------------------------------------------
// BEARBEITEN (Modal)
// ----------------------------------------------------------
function _openEditModal(dog) {
UI.modal.open({ title: `${dog.name} bearbeiten`, body: _formHTML(dog) });
UI.modal.open({ title: `${dog.name} bearbeiten`, body: _formHTML(dog, true) });
_bindForm(dog, true);
}
// ----------------------------------------------------------
// FORMULAR HTML
// ----------------------------------------------------------
function _formHTML(dog) {
function _formHTML(dog, inModal = false) {
const today = new Date().toISOString().slice(0, 10);
return `
<form id="dp-form" autocomplete="off">
@ -270,9 +294,25 @@ window.Page_dog_profile = (() => {
</label>
</div>
<div class="form-group">
<label class="form-label">Foto</label>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<img id="dp-form-preview"
src="${dog?.foto_url || ''}"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
background:var(--c-surface-2);border:2px solid var(--c-border);
display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
📷 Foto auswählen
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
${dog ? `<button type="button" class="btn btn-secondary flex-1"
id="dp-form-cancel">Abbrechen</button>` : ''}
${(dog || inModal) ? `<button type="button" class="btn btn-secondary flex-1"
id="dp-form-cancel">Abbrechen</button>` : ''}
<button type="submit" class="btn btn-primary flex-1">
${dog ? 'Speichern' : '🐕 Hund anlegen'}
</button>
@ -299,6 +339,22 @@ window.Page_dog_profile = (() => {
const form = document.getElementById('dp-form');
if (!form) return;
// Foto-Vorschau
const fotoInput = document.getElementById('dp-form-foto');
const fotoPreview = document.getElementById('dp-form-preview');
if (fotoInput && fotoPreview) {
fotoInput.addEventListener('change', () => {
const file = fotoInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
fotoPreview.src = e.target.result;
fotoPreview.style.display = 'block';
};
reader.readAsDataURL(file);
});
}
document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close);
@ -355,8 +411,29 @@ window.Page_dog_profile = (() => {
saved = await API.dogs.create(payload);
_appState.dogs.push(saved);
_appState.activeDog = saved;
localStorage.setItem('by_active_dog', String(saved.id));
if (inModal) UI.modal.close();
UI.toast.success(`${saved.name} wurde angelegt! 🎉`);
}
// Foto hochladen wenn gewählt
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
if (fotoFile) {
try {
const fd = new FormData();
fd.append('file', fotoFile);
const result = await API.dogs.uploadPhoto(saved.id, fd);
saved.foto_url = result.foto_url;
_appState.activeDog = { ...saved };
_appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d);
} catch {
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
}
}
// Dog Switcher in Header + Sidebar aktualisieren
App.renderDogSwitcher?.();
await _render();
});
});
@ -390,6 +467,6 @@ window.Page_dog_profile = (() => {
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, onDogChange };
return { init, refresh, onDogChange, addNew: _openCreateModal };
})();

View file

@ -0,0 +1,727 @@
/* ============================================================
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 = {}; // { impfung:[], tierarzt:[], gewicht:[], medikament:[], allergie:[], dokument:[] }
let _activeTab = 'impfung';
const TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '💉' },
{ key: 'tierarzt', label: 'Tierarzt', icon: '🏥' },
{ key: 'gewicht', label: 'Gewicht', icon: '⚖️' },
{ key: 'medikament', label: 'Medikamente',icon: '💊' },
{ key: 'allergie', label: 'Allergien', icon: '🌿' },
{ key: 'dokument', label: 'Dokumente', icon: '📄' },
];
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
async function refresh() {
if (!_appState.activeDog) return;
if (!_container.querySelector('#health-tabs')) {
await _render();
return;
}
await _loadAll();
_renderTab();
}
async function onDogChange() {
_data = {};
await _render();
}
function openNew() {
_showForm(null, _activeTab);
}
// ----------------------------------------------------------
// RENDER — Hauptstruktur
// ----------------------------------------------------------
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '💉',
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;
}
_container.innerHTML = `
<div class="health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
KI-Zusammenfassung
</button>
</div>
<div class="health-tabs" id="health-tabs"></div>
<div id="health-tab-content"></div>
`;
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
await _loadAll();
_renderTab();
}
function _renderTabBar() {
const tabsEl = _container.querySelector('#health-tabs');
tabsEl.innerHTML = TABS.map(t => `
<button class="health-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>
`).join('');
tabsEl.querySelectorAll('.health-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
tabsEl.querySelectorAll('.health-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 = {};
TABS.forEach(t => { _data[t.key] = []; });
all.forEach(e => {
if (_data[e.typ]) _data[e.typ].push(e);
});
} catch (err) {
UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.');
}
}
// ----------------------------------------------------------
// TAB-INHALT RENDERN
// ----------------------------------------------------------
function _renderTab() {
const content = _container.querySelector('#health-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 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
}
_bindTabEvents(content);
}
// ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status
// ----------------------------------------------------------
function _renderImpfungen(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💉', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
});
const items = entries.map(e => {
const ampel = _impfAmpel(e.naechstes);
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.tierarzt_name ? ` · ${_esc(e.tierarzt_name)}` : ''}
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
</div>
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`;
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
function _impfAmpel(naechstesStr) {
if (!naechstesStr) return { color: 'grey', label: 'Kein Folgedatum', icon: '' };
const diff = (new Date(naechstesStr) - Date.now()) / 86400000; // Tage
if (diff < 0) return { color: 'red', label: 'Überfällig!', icon: '🔴' };
if (diff < 60) return { color: 'yellow', label: 'Bald fällig', icon: '🟡' };
return { color: 'green', label: 'Aktuell', icon: '🟢' };
}
// ----------------------------------------------------------
// TIERARZTBESUCHE
// ----------------------------------------------------------
function _renderTierarzt(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🏥', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
});
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">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${e.tierarzt_name ? ` · ${_esc(e.tierarzt_name)}` : ''}
${e.kosten != null ? ` · ${Number(e.kosten).toFixed(2)}` : ''}
</div>
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// GEWICHT — mit SVG-Diagramm
// ----------------------------------------------------------
function _renderGewicht(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
if (!entries.length) return UI.emptyState({
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
});
const items = sorted.slice().reverse().map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title" style="font-size:var(--text-xl);font-weight:700">
${e.wert} ${e.einheit || 'kg'}
</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('');
return `
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''}
<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
}
function _weightChart(entries) {
const W = 320, H = 120, PAD = 24;
const vals = entries.map(e => parseFloat(e.wert));
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const pts = entries.map((e, i) => {
const x = PAD + (i / (entries.length - 1)) * (W - PAD * 2);
const y = PAD + (1 - (parseFloat(e.wert) - min) / range) * (H - PAD * 2);
return `${x},${y}`;
});
const dots = entries.map((e, i) => {
const [x, y] = pts[i].split(',');
return `<circle cx="${x}" cy="${y}" r="4" fill="var(--c-primary)"/>`;
}).join('');
const labels = [
`<text x="${PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)">${entries[0].datum.slice(5)}</text>`,
`<text x="${W - PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${entries[entries.length - 1].datum.slice(5)}</text>`,
`<text x="${PAD - 2}" y="${PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${max.toFixed(1)}</text>`,
`<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${min.toFixed(1)}</text>`,
].join('');
return `
<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:${W}px;display:block;margin:0 auto">
<polyline points="${pts.join(' ')}"
fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round"/>
${dots}
${labels}
</svg>
`;
}
// ----------------------------------------------------------
// 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: '💊', 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="health-group-label">${label}</div>
${items.map(e => `
<div class="health-card${e.aktiv ? '' : ' health-card--inactive'}" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${e.dosierung ? _esc(e.dosierung) : ''}
${e.haeufigkeit ? ` · ${_esc(e.haeufigkeit)}` : ''}
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('')}
` : '';
return `
<div class="health-list">
${renderGroup(aktive, '💊 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: '🌿', title: 'Noch keine Allergien eingetragen', action: addBtn
});
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
const items = entries.map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">
${e.schweregrad ? SCHWEREGRAD[e.schweregrad] || '' : ''} ${_esc(e.bezeichnung)}
</div>
<div class="health-card-meta">
Erstmals: ${UI.time.format(e.datum + 'T00:00:00')}
${e.schweregrad ? ` · Schweregrad: ${_esc(e.schweregrad)}` : ''}
</div>
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// DOKUMENTE
// ----------------------------------------------------------
function _renderDokumente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
if (!entries.length) return UI.emptyState({
icon: '📄', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
});
const items = entries.map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body" style="display:flex;gap:var(--space-3);align-items:center">
${e.datei_url
? (e.datei_typ === 'pdf'
? `<div class="health-doc-icon">📄</div>`
: `<img src="${e.datei_url}" class="health-doc-thumb" alt="Dokument">`)
: `<div class="health-doc-icon">📎</div>`}
<div>
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</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));
});
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// ----------------------------------------------------------
function _openDetail(entry) {
const tabInfo = TABS.find(t => t.key === entry.typ) || TABS[0];
const fields = _detailFields(entry);
const body = `
<div class="health-detail">
${fields}
${entry.datei_url
? (entry.datei_typ === 'pdf'
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)">📄 PDF öffnen</a>`
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''}
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)">
<button class="btn btn-secondary flex-1" id="health-detail-edit">Bearbeiten</button>
<button class="btn btn-danger flex-1" id="health-detail-delete">Löschen</button>
</div>
`;
UI.modal.open({ title: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`, body });
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_showForm(entry, entry.typ);
});
document.getElementById('health-detail-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Eintrag löschen?',
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
confirmText: 'Löschen',
danger: true,
});
if (ok) {
try {
await API.health.delete(_appState.activeDog.id, entry.id);
_data[entry.typ] = (_data[entry.typ] || []).filter(e => e.id !== entry.id);
UI.modal.close();
_renderTab();
UI.toast.success('Eintrag gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
}
});
}
function _detailFields(e) {
const rows = [];
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
if (e.naechstes) rows.push(['Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
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;
const today = new Date().toISOString().slice(0, 10);
const t = typ || _activeTab;
const commonFields = `
<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">Datum *</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
`;
const extraFields = _extraFormFields(entry, t);
const notizField = `
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="3">${_esc(entry?.notiz || '')}</textarea>
</div>
`;
const uploadField = t === 'dokument' ? `
<div class="form-group">
<label class="form-label">Datei (JPG, PNG, PDF)</label>
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
</div>
` : '';
const body = `
<form id="health-form" autocomplete="off">
${commonFields}
${extraFields}
${notizField}
${uploadField}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Erstellen'}</button>
</div>
</form>
`;
const tabInfo = TABS.find(tab => tab.key === t) || TABS[0];
UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body });
const form = document.getElementById('health-form');
setTimeout(() => form?.querySelector('[name="bezeichnung"]')?.focus(), 150);
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
form.addEventListener('submit', async e => {
e.preventDefault();
const btn = form.querySelector('[type="submit"]');
const fd = UI.formData(form);
await UI.asyncButton(btn, async () => {
const payload = _buildPayload(fd, t);
let saved;
if (isEdit) {
saved = await API.health.update(_appState.activeDog.id, entry.id, payload);
const idx = (_data[t] || []).findIndex(x => x.id === entry.id);
if (idx !== -1) _data[t][idx] = saved;
UI.toast.success('Gespeichert.');
} else {
saved = await API.health.create(_appState.activeDog.id, { ...payload, typ: t });
if (!_data[t]) _data[t] = [];
_data[t].unshift(saved);
UI.toast.success('Eintrag erstellt.');
}
// Datei-Upload für Dokumente
if (t === 'dokument' && form.querySelector('[name="datei"]')?.files[0]) {
try {
const formData = new FormData();
formData.append('file', form.querySelector('[name="datei"]').files[0]);
const res = await API.health.uploadDokument(_appState.activeDog.id, saved.id, formData);
saved.datei_url = res.datei_url;
saved.datei_typ = res.datei_typ;
} catch {
UI.toast.warning('Eintrag erstellt, Datei konnte nicht hochgeladen werden.');
}
}
UI.modal.close();
_renderTab();
});
});
}
function _formPlaceholder(typ) {
const ph = {
impfung: 'z.B. Tollwut, DHPP, Leptospirose',
tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung',
gewicht: '',
medikament: 'z.B. Frontline, Milbemax',
allergie: 'z.B. Hühnchen, Gras, Hausstaub',
dokument: 'z.B. Impfpass, Blutbild',
};
return ph[typ] || '';
}
function _extraFormFields(entry, typ) {
switch (typ) {
case 'impfung': return `
<div class="form-group">
<label class="form-label">Nächste Impfung (optional)</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input class="form-control" type="text" name="tierarzt_name" value="${_esc(entry?.tierarzt_name || '')}">
</div>
<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 class="form-group">
<label class="form-label">Nächste Behandlung (optional)</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input class="form-control" type="text" name="tierarzt_name" value="${_esc(entry?.tierarzt_name || '')}">
</div>
`;
case 'tierarzt': return `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<input class="form-control" type="text" name="tierarzt_name" value="${_esc(entry?.tierarzt_name || '')}">
</div>
<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.1" 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 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>
<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>
`;
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);
if (fd.kosten) p.kosten = parseFloat(fd.kosten);
if (typ === 'medikament') {
p.aktiv = 'aktiv' in fd ? 1 : 0;
}
// Gewicht-Einheit
p.einheit = fd.einheit || 'kg';
return p;
}
// ----------------------------------------------------------
// 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: '✨ KI-Gesundheitsbericht',
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
});
} catch (err) {
if (err.status === 503) {
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
} else if (err.status === 402) {
UI.toast.warning('Diese Funktion ist Teil von Ban Yaro Premium.');
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Zusammenfassung.');
}
} finally {
UI.setLoading(btn, false);
}
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh, openNew, onDogChange };
})();

View file

@ -392,27 +392,56 @@ window.Page_poison = (() => {
}
});
document.getElementById('detail-resolve')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Meldung als erledigt markieren?',
message: 'Das Problem wurde beseitigt oder die Meldung war fehlerhaft.',
confirmText: 'Erledigt markieren',
});
if (!ok) return;
try {
await API.poison.resolve(r.id);
_reports = _reports.filter(x => x.id !== r.id);
_markers.splice(_reports.length, 1); // cleanup (wird bei _renderMarkers neu gesetzt)
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.toast.success('Meldung als erledigt markiert.');
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
}
document.getElementById('detail-resolve')?.addEventListener('click', () => {
_showResolveDialog(r);
});
}
// ----------------------------------------------------------
// ERLEDIGT-DIALOG — mit Grundauswahl für KI-Analyse
// ----------------------------------------------------------
function _showResolveDialog(r) {
UI.modal.open({
title: '✔ Meldung als erledigt markieren',
body: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Die Meldung wird inaktiv gesetzt. Die Daten bleiben für spätere
Musteranalysen gespeichert.
</p>
<div class="form-group">
<label class="form-label">Grund</label>
<select class="form-control" id="resolve-grund">
<option value="beseitigt"> Gefahr wurde beseitigt</option>
<option value="fehlerhaft"> Meldung war fehlerhaft</option>
<option value="anderes">💬 Anderer Grund</option>
</select>
</div>
`,
footer: `
<button class="btn btn-secondary" id="resolve-cancel">Abbrechen</button>
<button class="btn btn-nature" id="resolve-confirm">Erledigt markieren</button>
`,
});
document.getElementById('resolve-cancel')
?.addEventListener('click', UI.modal.close);
document.getElementById('resolve-confirm')
?.addEventListener('click', async () => {
const grund = document.getElementById('resolve-grund')?.value || 'beseitigt';
const btn = document.getElementById('resolve-confirm');
await UI.asyncButton(btn, async () => {
await API.poison.resolve(r.id, { grund });
_reports = _reports.filter(x => x.id !== r.id);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.modal.close();
UI.toast.success('Meldung als erledigt markiert.');
});
});
}
// ----------------------------------------------------------
// MELDE-FORMULAR
// ----------------------------------------------------------

View file

@ -101,10 +101,10 @@ const UI = (() => {
onClose: () => resolve(false),
});
m.parentElement.querySelector('#modal-cancel').addEventListener('click', () => {
close(); resolve(false);
resolve(false); close();
});
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
close(); resolve(true);
resolve(true); close();
});
});
}