Feature: KI-Jahresberichte speichern + Archiv + Download — SW by-v505, APP_VER 482

This commit is contained in:
rene 2026-04-29 17:03:49 +02:00
parent b4de0aa27c
commit 9832cd24d8
6 changed files with 146 additions and 17 deletions

View file

@ -1469,3 +1469,16 @@ def _migrate(conn_factory):
ex ex
) )
logger.info(f"Migration: Übung '{ex[1]}' eingefügt.") logger.info(f"Migration: Übung '{ex[1]}' eingefügt.")
# Gespeicherte KI-Jahresberichte für Züchter
conn.executescript("""
CREATE TABLE IF NOT EXISTS breeder_jahresberichte (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
breeder_id INTEGER NOT NULL,
jahr INTEGER NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_bj_user ON breeder_jahresberichte(user_id, jahr DESC);
""")

View file

@ -462,7 +462,45 @@ Zeitraum: letzte 2 Jahre (bis {date.today().isoformat()})
requires_premium=False, requires_premium=False,
user_id=user["id"], user_id=user["id"],
) )
return {"text": text}
except Exception as e: except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}") logger.warning(f"KI nicht verfügbar: {e}")
return {"text": _FALLBACK} text = _FALLBACK
# Bericht speichern
jahr = date.today().year
with db() as conn:
bp2 = conn.execute("SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)).fetchone()
if bp2:
conn.execute(
"INSERT INTO breeder_jahresberichte (user_id, breeder_id, jahr, text) VALUES (?,?,?,?)",
(user["id"], bp2["id"], jahr, text)
)
saved_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
else:
saved_id = None
return {"text": text, "saved_id": saved_id, "jahr": jahr}
# GET gespeicherte Berichte
# ------------------------------------------------------------------
@router.get("/zucht-ki/jahresbericht")
async def jahresbericht_list(user=Depends(_require_breeder)):
with db() as conn:
rows = conn.execute(
"SELECT id, jahr, created_at FROM breeder_jahresberichte WHERE user_id=? ORDER BY created_at DESC LIMIT 20",
(user["id"],)
).fetchall()
return [{"id": r["id"], "jahr": r["jahr"], "created_at": r["created_at"]} for r in rows]
@router.get("/zucht-ki/jahresbericht/{bericht_id}")
async def jahresbericht_get(bericht_id: int, user=Depends(_require_breeder)):
with db() as conn:
row = conn.execute(
"SELECT id, jahr, text, created_at FROM breeder_jahresberichte WHERE id=? AND user_id=?",
(bericht_id, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Bericht nicht gefunden.")
return dict(row)

View file

@ -692,6 +692,8 @@ const API = (() => {
}, },
hundBeschreibung(hundId) { return post('/zucht-ki/hund-beschreibung', { hund_id: hundId }); }, hundBeschreibung(hundId) { return post('/zucht-ki/hund-beschreibung', { hund_id: hundId }); },
jahresbericht() { return post('/zucht-ki/jahresbericht', {}); }, jahresbericht() { return post('/zucht-ki/jahresbericht', {}); },
jahresberichtList() { return get('/zucht-ki/jahresbericht'); },
jahresberichtGet(id) { return get(`/zucht-ki/jahresbericht/${id}`); },
}; };
const osm = { const osm = {

View file

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

View file

@ -117,6 +117,9 @@ window.Page_zuchthunde = (() => {
${_appState?.user?.ki_zucht_jahresbericht !== 0 ? ` ${_appState?.user?.ki_zucht_jahresbericht !== 0 ? `
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-btn"> <a class="btn btn-ghost btn-sm" id="zh-jahresbericht-btn">
${UI.icon('chart-bar')} Jahresbericht ${UI.icon('chart-bar')} Jahresbericht
</a>
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-archiv-btn" title="Frühere Berichte">
${UI.icon('archive')}
</a>` : ''} </a>` : ''}
</div> </div>
<div style="padding:0 0 var(--space-3)"> <div style="padding:0 0 var(--space-3)">
@ -132,6 +135,7 @@ window.Page_zuchthunde = (() => {
document.getElementById('zh-new-btn')?.addEventListener('click', () => _showHundForm(null)); document.getElementById('zh-new-btn')?.addEventListener('click', () => _showHundForm(null));
document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal()); document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal());
document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht()); document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht());
document.getElementById('zh-jahresbericht-archiv-btn')?.addEventListener('click', () => _showJahresberichtArchiv());
document.getElementById('zh-search')?.addEventListener('input', e => { document.getElementById('zh-search')?.addEventListener('input', e => {
_query = e.target.value.toLowerCase().trim(); _query = e.target.value.toLowerCase().trim();
@ -1285,7 +1289,7 @@ window.Page_zuchthunde = (() => {
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// KI: Jahresbericht // KI: Jahresbericht generieren
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _showJahresbericht() { async function _showJahresbericht() {
UI.modal.open({ UI.modal.open({
@ -1294,10 +1298,12 @@ window.Page_zuchthunde = (() => {
footer: '', footer: '',
}); });
let text = ''; let text = '', savedId = null, jahr = new Date().getFullYear();
try { try {
const result = await API.zuchtKi.jahresbericht(); const result = await API.zuchtKi.jahresbericht();
text = result.text || result.content || result.bericht || JSON.stringify(result); text = result.text || result.content || result.bericht || JSON.stringify(result);
savedId = result.saved_id;
jahr = result.jahr || jahr;
} catch (err) { } catch (err) {
UI.modal.open({ UI.modal.open({
title: `${UI.icon('chart-bar')} KI-Jahresbericht`, title: `${UI.icon('chart-bar')} KI-Jahresbericht`,
@ -1307,23 +1313,93 @@ window.Page_zuchthunde = (() => {
return; return;
} }
_renderBerichtModal(text, jahr, savedId);
}
function _renderBerichtModal(text, jahr, savedId) {
UI.modal.open({ UI.modal.open({
title: `${UI.icon('chart-bar')} KI-Jahresbericht`, title: `${UI.icon('chart-bar')} KI-Jahresbericht ${jahr}`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`, body: `
${savedId ? `<p style="font-size:var(--text-xs);color:var(--c-success);margin:0 0 var(--space-3);display:flex;align-items:center;gap:4px">
${UI.icon('check-circle')} Automatisch gespeichert</p>` : ''}
<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
footer: ` footer: `
<button class="btn btn-secondary flex-1" id="ki-bericht-copy"> <button class="btn btn-ghost btn-sm" id="ki-bericht-copy">
${UI.icon('clipboard-text')} Kopieren ${UI.icon('clipboard-text')} Kopieren
</button> </button>
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`, <button class="btn btn-ghost btn-sm" id="ki-bericht-download">
${UI.icon('download-simple')} Herunterladen
</button>
<button class="btn btn-primary" data-modal-close>Schließen</button>`,
}); });
document.getElementById('ki-bericht-copy')?.addEventListener('click', async () => { document.getElementById('ki-bericht-copy')?.addEventListener('click', async () => {
try { try { await navigator.clipboard.writeText(text); UI.toast.success('Bericht kopiert.'); }
await navigator.clipboard.writeText(text); catch { UI.toast.error('Kopieren nicht möglich.'); }
UI.toast.success('Bericht kopiert.'); });
} catch {
UI.toast.error('Kopieren nicht möglich.'); document.getElementById('ki-bericht-download')?.addEventListener('click', () => {
} const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `ban-yaro-jahresbericht-${jahr}.txt`;
a.click(); URL.revokeObjectURL(url);
});
}
// KI: Archiv früherer Berichte
// ----------------------------------------------------------
async function _showJahresberichtArchiv() {
UI.modal.open({
title: `${UI.icon('archive')} Gespeicherte Jahresberichte`,
body: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-4)">Lädt…</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
let berichte = [];
try { berichte = await API.zuchtKi.jahresberichtList(); }
catch { UI.toast.error('Berichte konnten nicht geladen werden.'); return; }
if (!berichte.length) {
UI.modal.open({
title: `${UI.icon('archive')} Gespeicherte Jahresberichte`,
body: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Noch keine Berichte gespeichert.</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
return;
}
const listHtml = berichte.map(b => `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border-light)">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">
Jahresbericht ${b.jahr}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${new Date(b.created_at).toLocaleDateString('de', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}
</div>
</div>
<button class="btn btn-ghost btn-sm" data-bericht-id="${b.id}" data-bericht-jahr="${b.jahr}">
${UI.icon('eye')} Lesen
</button>
</div>`).join('');
UI.modal.open({
title: `${UI.icon('archive')} Gespeicherte Jahresberichte`,
body: `<div style="padding:0 var(--space-1)">${listHtml}</div>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
document.querySelectorAll('[data-bericht-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = Number(btn.dataset.berichtId);
const jahr = Number(btn.dataset.berichtJahr);
try {
const r = await API.zuchtKi.jahresberichtGet(id);
_renderBerichtModal(r.text, r.jahr || jahr, r.id);
} catch { UI.toast.error('Bericht konnte nicht geladen werden.'); }
});
}); });
} }

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v504'; const CACHE_VERSION = 'by-v505';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten