Feat: Gesundheits-Erinnerungen mit Wiederkehrend-Intervall
- intervall_tage Feld (monatlich/vierteljährlich/jährlich etc.) - Impfung, Entwurmung, Medikament: Intervall-Auswahl im Formular - Erinnerungs-Banner über den Tabs: zeigt alle Einträge die in ≤60 Tagen fällig sind - Ampel-Farbe am linken Rand (rot=überfällig, gelb=bald, grün=ok) - "✓ Erledigt"-Button öffnet neues Formular vorausgefüllt mit heute + nächstem Termin - Nav-Badge (Zahl) auf Gesundheit-Icon wenn Einträge überfällig/bald fällig - _showForm: isEdit prüft entry?.id statt !!entry (Reminder-Flow) - SW-Cache → by-v16
This commit is contained in:
parent
5c178f812b
commit
b8a5dc7a66
4 changed files with 165 additions and 11 deletions
|
|
@ -301,6 +301,8 @@ def _migrate(conn_factory):
|
||||||
("tieraerzte", "strasse", "TEXT"),
|
("tieraerzte", "strasse", "TEXT"),
|
||||||
("tieraerzte", "plz", "TEXT"),
|
("tieraerzte", "plz", "TEXT"),
|
||||||
("tieraerzte", "ort", "TEXT"),
|
("tieraerzte", "ort", "TEXT"),
|
||||||
|
# Gesundheit: Erinnerungsintervall für wiederkehrende Einträge
|
||||||
|
("health", "intervall_tage", "INTEGER"),
|
||||||
]
|
]
|
||||||
with conn_factory() as conn:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class HealthCreate(BaseModel):
|
||||||
schweregrad: Optional[str] = None # leicht | mittel | schwer
|
schweregrad: Optional[str] = None # leicht | mittel | schwer
|
||||||
reaktion: Optional[str] = None
|
reaktion: Optional[str] = None
|
||||||
erinnerung: Optional[int] = 1
|
erinnerung: Optional[int] = 1
|
||||||
|
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
|
||||||
# Tierarzt-Verknüpfung
|
# Tierarzt-Verknüpfung
|
||||||
tierarzt_id: Optional[int] = None
|
tierarzt_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
@ -64,6 +65,7 @@ class HealthUpdate(BaseModel):
|
||||||
schweregrad: Optional[str] = None
|
schweregrad: Optional[str] = None
|
||||||
reaktion: Optional[str] = None
|
reaktion: Optional[str] = None
|
||||||
erinnerung: Optional[int] = None
|
erinnerung: Optional[int] = None
|
||||||
|
intervall_tage: Optional[int] = None
|
||||||
tierarzt_id: Optional[int] = None
|
tierarzt_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ window.Page_health = (() => {
|
||||||
✨ KI-Zusammenfassung
|
✨ KI-Zusammenfassung
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="health-reminders"></div>
|
||||||
<div class="health-tabs" id="health-tabs"></div>
|
<div class="health-tabs" id="health-tabs"></div>
|
||||||
<div id="health-tab-content"></div>
|
<div id="health-tab-content"></div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -131,9 +132,126 @@ window.Page_health = (() => {
|
||||||
.addEventListener('click', _showKiSummary);
|
.addEventListener('click', _showKiSummary);
|
||||||
|
|
||||||
await _loadAll();
|
await _loadAll();
|
||||||
|
_renderErinnerungen();
|
||||||
_renderTab();
|
_renderTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// ERINNERUNGEN — Banner über den Tabs
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _getErinnerungen() {
|
||||||
|
const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament'];
|
||||||
|
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: '💉', entwurmung: '🪱', medikament: '💊' };
|
||||||
|
|
||||||
|
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)">
|
||||||
|
📅 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] || '📋'}</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">
|
||||||
|
✓ 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() {
|
function _renderTabBar() {
|
||||||
const tabsEl = _container.querySelector('#health-tabs');
|
const tabsEl = _container.querySelector('#health-tabs');
|
||||||
tabsEl.innerHTML = TABS.map(t => `
|
tabsEl.innerHTML = TABS.map(t => `
|
||||||
|
|
@ -624,7 +742,7 @@ window.Page_health = (() => {
|
||||||
// FORMULAR — Neu / Bearbeiten
|
// FORMULAR — Neu / Bearbeiten
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _showForm(entry, typ) {
|
function _showForm(entry, typ) {
|
||||||
const isEdit = !!entry;
|
const isEdit = !!(entry?.id);
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const t = typ || _activeTab;
|
const t = typ || _activeTab;
|
||||||
|
|
||||||
|
|
@ -739,6 +857,28 @@ window.Page_health = (() => {
|
||||||
return ph[typ] || '';
|
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
|
// Wiederverwendbares Praxis-Dropdown für alle Formulare
|
||||||
function _praxisSelectField(entry) {
|
function _praxisSelectField(entry) {
|
||||||
const aktivePraxen = _praxen.filter(p => p.aktiv);
|
const aktivePraxen = _praxen.filter(p => p.aktiv);
|
||||||
|
|
@ -759,9 +899,12 @@ window.Page_health = (() => {
|
||||||
function _extraFormFields(entry, typ) {
|
function _extraFormFields(entry, typ) {
|
||||||
switch (typ) {
|
switch (typ) {
|
||||||
case 'impfung': return `
|
case 'impfung': return `
|
||||||
<div class="form-group">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<label class="form-label">Nächste Impfung (optional)</label>
|
<div class="form-group">
|
||||||
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
<label class="form-label">Nächste Impfung</label>
|
||||||
|
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
||||||
|
</div>
|
||||||
|
${_intervallField(entry)}
|
||||||
</div>
|
</div>
|
||||||
${_praxisSelectField(entry)}
|
${_praxisSelectField(entry)}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -770,9 +913,12 @@ window.Page_health = (() => {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
case 'entwurmung': return `
|
case 'entwurmung': return `
|
||||||
<div class="form-group">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<label class="form-label">Nächste Behandlung (optional)</label>
|
<div class="form-group">
|
||||||
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
<label class="form-label">Nächste Behandlung</label>
|
||||||
|
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
|
||||||
|
</div>
|
||||||
|
${_intervallField(entry)}
|
||||||
</div>
|
</div>
|
||||||
${_praxisSelectField(entry)}
|
${_praxisSelectField(entry)}
|
||||||
`;
|
`;
|
||||||
|
|
@ -836,9 +982,12 @@ window.Page_health = (() => {
|
||||||
<input class="form-control" type="text" name="haeufigkeit"
|
<input class="form-control" type="text" name="haeufigkeit"
|
||||||
value="${_esc(entry?.haeufigkeit || '')}" placeholder="z.B. täglich, 2x wöchentlich">
|
value="${_esc(entry?.haeufigkeit || '')}" placeholder="z.B. täglich, 2x wöchentlich">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<label class="form-label">Gabe bis (optional)</label>
|
<div class="form-group">
|
||||||
<input class="form-control" type="date" name="bis_datum" value="${entry?.bis_datum || ''}">
|
<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>
|
</div>
|
||||||
${_praxisSelectField(entry)}
|
${_praxisSelectField(entry)}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -896,6 +1045,7 @@ window.Page_health = (() => {
|
||||||
if (typ === 'medikament') {
|
if (typ === 'medikament') {
|
||||||
p.aktiv = 'aktiv' in fd ? 1 : 0;
|
p.aktiv = 'aktiv' in fd ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
p.intervall_tage = fd.intervall_tage ? parseInt(fd.intervall_tage) : null;
|
||||||
// Gewicht-Einheit
|
// Gewicht-Einheit
|
||||||
p.einheit = fd.einheit || 'kg';
|
p.einheit = fd.einheit || 'kg';
|
||||||
return p;
|
return p;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications
|
Offline-Cache + Push Notifications
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v15';
|
const CACHE_VERSION = 'by-v16';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
|
|
||||||
// Diese Dateien werden beim Install gecacht (App Shell)
|
// Diese Dateien werden beim Install gecacht (App Shell)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue