Teil 3: Terminvorschläge + KI-Limit-Bypass für Admins/Mods — SW by-v435, APP_VER 414

- timeutils: next_appointment_slot() parst OSM opening_hours, findet Slot
- GET /health/terminvorschlaege: fällige/überfällige Einträge (30-Tage-Horizont)
  Impfung/Tierarzt nutzen Praxis-Öffnungszeiten, Rest nächster Werktag 09:00
- Frontend: Terminvorschlags-Karten, bestätigbares Modal, legt Event an
- ki.py: Admins, Moderatoren, Media Manager bypassen CLOUD_WEEKLY_LIMIT
This commit is contained in:
rene 2026-04-26 17:08:18 +02:00
parent 570dcd4e93
commit c935d3fbd4
7 changed files with 300 additions and 9 deletions

View file

@ -168,7 +168,8 @@ const API = (() => {
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
terminvorschlaege(dogId) { return get(`/dogs/${dogId}/health/terminvorschlaege`); },
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '413'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '414'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -153,6 +153,7 @@ window.Page_health = (() => {
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></div>
<div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
@ -168,6 +169,7 @@ window.Page_health = (() => {
_renderErinnerungen();
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
}
// ----------------------------------------------------------
@ -1990,6 +1992,126 @@ window.Page_health = (() => {
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
// TERMINVORSCHLÄGE
// ----------------------------------------------------------
async function _loadTerminvorschlaege(dogId) {
const el = _container.querySelector('#health-terminvorschlaege');
if (!el) return;
try {
const vorschlaege = await API.health.terminvorschlaege(dogId);
if (!vorschlaege || !vorschlaege.length) return;
const _fmtDatum = iso => new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric'
});
el.innerHTML = `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Terminvorschläge
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${vorschlaege.map(v => {
const badge = v.ueberfaellig
? `<span style="font-size:var(--text-xs);color:var(--c-danger);font-weight:600">Überfällig seit ${_fmtDatum(v.naechstes)}</span>`
: `<span style="font-size:var(--text-xs);color:var(--c-warning);font-weight:600">Fällig am ${_fmtDatum(v.naechstes)}</span>`;
return `
<div class="health-card" style="flex-direction:row;align-items:center;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.bezeichnung)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(v.label)}${v.praxis_name ? ' · ' + _esc(v.praxis_name) : ''}</div>
${badge}
</div>
<div style="text-align:right;flex-shrink:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_fmtDatum(v.datum_vorschlag)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${v.uhrzeit_vorschlag} Uhr</div>
<button class="btn btn-primary btn-sm" style="margin-top:var(--space-1)"
data-action="termin-anlegen"
data-v='${_esc(JSON.stringify(v))}'>
📅 In Kalender
</button>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
el.querySelectorAll('[data-action="termin-anlegen"]').forEach(btn => {
btn.addEventListener('click', async () => {
let v;
try { v = JSON.parse(btn.dataset.v); } catch { return; }
await _terminAnlegen(v, btn);
});
});
} catch { /* still show health page if this fails */ }
}
async function _terminAnlegen(v, btn) {
const titel = v.beim_tierarzt
? `${v.label}: ${v.bezeichnung} (Tierarzt)`
: `${v.label}: ${v.bezeichnung}`;
const beschreibung = v.praxis_name
? `Praxis: ${v.praxis_name}`
: v.ueberfaellig
? `Überfällig seit ${v.naechstes}`
: `Fällig am ${v.naechstes}`;
UI.modal.open({
title: '📅 Termin in Kalender eintragen',
body: `
<form id="termin-form">
<div class="form-group">
<label class="form-label">Bezeichnung</label>
<input class="form-control" type="text" name="titel" value="${_esc(titel)}" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum</label>
<input class="form-control" type="date" name="datum" value="${_esc(v.datum_vorschlag)}" required>
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input class="form-control" type="time" name="uhrzeit" value="${_esc(v.uhrzeit_vorschlag)}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input class="form-control" type="text" name="beschreibung" value="${_esc(beschreibung)}">
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="termin-cancel">Abbrechen</button>
<button type="submit" form="termin-form" class="btn btn-primary flex-1">Speichern</button>
`,
});
document.getElementById('termin-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('termin-form')?.addEventListener('submit', async e => {
e.preventDefault();
const saveBtn = document.querySelector('[form="termin-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(saveBtn, async () => {
await API.events.create({
titel: fd.titel,
datum: fd.datum,
uhrzeit: fd.uhrzeit || null,
beschreibung: fd.beschreibung || null,
typ: v.beim_tierarzt ? 'tierarzt' : 'sonstiges',
lat: v.praxis_lat ?? null,
lon: v.praxis_lon ?? null,
ort_name: v.praxis_name ?? null,
});
UI.modal.close();
UI.toast.success('Termin gespeichert — erscheint in deinem Kalender.');
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');