Feature: Tagebuch-Import (NoteStation .nsx + CSV) + Transponder in Gesundheitsdaten
- Import-Endpoint für Synology NoteStation (.nsx): HTML→Text, GPS, Bilder, Unix-Timestamp→Datum - Import-Endpoint für CSV (Komma/Semikolon, BOM-safe, DE-Datumsformat) - Import-Modal im Tagebuch mit Format-Auswahl-Karten und Ergebnis-Anzeige - Transpondernummer in Gesundheitsdaten: Anzeige + Inline-Edit via Modal - SW-Cache by-v142
This commit is contained in:
parent
6fcf841594
commit
94e0ed3daa
7 changed files with 505 additions and 5 deletions
|
|
@ -70,9 +70,10 @@ from routes.wiki import router as wiki_router
|
|||
from routes.movies import router as movies_router
|
||||
from routes.friends import router as friends_router
|
||||
from routes.chat import router as chat_router
|
||||
from routes.admin import router as admin_router
|
||||
from routes.webcal import router as webcal_router
|
||||
from routes.profile import router as profile_router
|
||||
from routes.admin import router as admin_router
|
||||
from routes.webcal import router as webcal_router
|
||||
from routes.profile import router as profile_router
|
||||
from routes.import_data import router as import_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -98,6 +99,7 @@ app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
|
|||
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
||||
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
270
backend/routes/import_data.py
Normal file
270
backend/routes/import_data.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""BAN YARO — Import: Synology NoteStation (.nsx) + CSV"""
|
||||
|
||||
import os, io, uuid, json, zipfile, csv, re, datetime
|
||||
from html.parser import HTMLParser
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
MAX_NSX_MB = 200
|
||||
MAX_CSV_MB = 10
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTML → Plaintext
|
||||
# ------------------------------------------------------------------
|
||||
class _HTMLStripper(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.parts = []
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(data)
|
||||
|
||||
def handle_entityref(self, name):
|
||||
if name == "nbsp":
|
||||
self.parts.append(" ")
|
||||
|
||||
def handle_charref(self, name):
|
||||
if name in ("160", "xa0"):
|
||||
self.parts.append(" ")
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
if not html:
|
||||
return ""
|
||||
# Replace block-level tags with newlines before stripping
|
||||
html = re.sub(r"</?(div|p|br|h[1-6]|li)[^>]*>", "\n", html, flags=re.IGNORECASE)
|
||||
s = _HTMLStripper()
|
||||
s.feed(html)
|
||||
text = "".join(s.parts)
|
||||
# Collapse excessive blank lines
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helfer: Bild aus ZIP in Media-Ordner speichern
|
||||
# ------------------------------------------------------------------
|
||||
def _save_image_from_zip(zf: zipfile.ZipFile, md5: str, mime: str) -> str | None:
|
||||
zip_name = f"file_{md5}"
|
||||
if zip_name not in zf.namelist():
|
||||
return None
|
||||
ext = mime.split("/")[-1] if mime else "jpg"
|
||||
ext = ext.replace("jpeg", "jpg")
|
||||
dest_dir = os.path.join(MEDIA_DIR, "diary")
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
filename = f"import_{uuid.uuid4().hex}.{ext}"
|
||||
dest = os.path.join(dest_dir, filename)
|
||||
with zf.open(zip_name) as src, open(dest, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
return f"/media/diary/{filename}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/import/notestation
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/notestation")
|
||||
async def import_notestation(
|
||||
dog_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
if not file.filename.lower().endswith(".nsx"):
|
||||
raise HTTPException(400, "Bitte eine .nsx-Datei hochladen.")
|
||||
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_NSX_MB * 1024 * 1024:
|
||||
raise HTTPException(413, f"Datei zu groß (max {MAX_NSX_MB} MB).")
|
||||
|
||||
with db() as conn:
|
||||
dog = conn.execute(
|
||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||
).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(raw))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Ungültige .nsx-Datei (kein ZIP).")
|
||||
|
||||
config = json.loads(zf.read("config.json"))
|
||||
note_ids = config.get("note", [])
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
with db() as conn:
|
||||
for nid in note_ids:
|
||||
try:
|
||||
note = json.loads(zf.read(nid))
|
||||
except Exception as e:
|
||||
errors.append(f"{nid}: Lesefehler – {e}")
|
||||
continue
|
||||
|
||||
if note.get("category") == "notebook":
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Datum aus ctime (Unix-Timestamp)
|
||||
ctime = note.get("ctime") or note.get("mtime")
|
||||
if ctime:
|
||||
datum = datetime.datetime.fromtimestamp(ctime).strftime("%Y-%m-%d")
|
||||
else:
|
||||
datum = datetime.date.today().isoformat()
|
||||
|
||||
titel = (note.get("title") or "").strip() or None
|
||||
text = _html_to_text(note.get("content", "")) or None
|
||||
lat = note.get("latitude") or None
|
||||
lon = note.get("longitude") or None
|
||||
# Koordinate (0,0) ist kein echter Ort
|
||||
if lat == 0.0:
|
||||
lat = None
|
||||
if lon == 0.0:
|
||||
lon = None
|
||||
tags = note.get("tag") or []
|
||||
|
||||
# Eintrag anlegen
|
||||
conn.execute(
|
||||
"""INSERT INTO diary (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
|
||||
VALUES (?, ?, 'eintrag', ?, ?, ?, ?, ?, 0)""",
|
||||
(dog_id, datum, titel, text, json.dumps(tags), lat, lon),
|
||||
)
|
||||
entry_id = conn.execute(
|
||||
"SELECT last_insert_rowid() AS id"
|
||||
).fetchone()["id"]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
|
||||
(entry_id, dog_id),
|
||||
)
|
||||
|
||||
# Erstes Bild speichern
|
||||
attachments = note.get("attachment") or {}
|
||||
media_url = None
|
||||
for att in attachments.values():
|
||||
md5 = att.get("md5", "")
|
||||
mime = att.get("type", "image/jpeg")
|
||||
if not mime.startswith("image/"):
|
||||
continue
|
||||
media_url = _save_image_from_zip(zf, md5, mime)
|
||||
if media_url:
|
||||
break
|
||||
|
||||
if media_url:
|
||||
conn.execute(
|
||||
"UPDATE diary SET media_url=? WHERE id=?",
|
||||
(media_url, entry_id),
|
||||
)
|
||||
|
||||
imported += 1
|
||||
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/import/csv
|
||||
# Spalten (Header-Zeile): datum, titel, text, tags, gps_lat, gps_lon, is_milestone
|
||||
# Trenner: Komma oder Semikolon | Encoding: UTF-8 (mit oder ohne BOM)
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/csv")
|
||||
async def import_csv(
|
||||
dog_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
if not file.filename.lower().endswith(".csv"):
|
||||
raise HTTPException(400, "Bitte eine .csv-Datei hochladen.")
|
||||
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_CSV_MB * 1024 * 1024:
|
||||
raise HTTPException(413, f"Datei zu groß (max {MAX_CSV_MB} MB).")
|
||||
|
||||
with db() as conn:
|
||||
dog = conn.execute(
|
||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||
).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
|
||||
text = raw.decode("utf-8-sig") # utf-8-sig entfernt BOM falls vorhanden
|
||||
# Semikolon oder Komma erkennen
|
||||
first_line = text.split("\n")[0]
|
||||
delimiter = ";" if first_line.count(";") >= first_line.count(",") else ","
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
|
||||
# Normalisierte Header (lowercase, strip)
|
||||
reader.fieldnames = [h.strip().lower() for h in (reader.fieldnames or [])]
|
||||
|
||||
REQUIRED = {"datum", "titel", "text"}
|
||||
if not REQUIRED.issubset(set(reader.fieldnames)):
|
||||
missing = REQUIRED - set(reader.fieldnames)
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"CSV fehlen Pflicht-Spalten: {', '.join(sorted(missing))}. "
|
||||
"Erwartet: datum, titel, text (+ optional: tags, gps_lat, gps_lon, is_milestone)"
|
||||
)
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
with db() as conn:
|
||||
for i, row in enumerate(reader, start=2):
|
||||
try:
|
||||
datum = (row.get("datum") or "").strip()
|
||||
if not datum:
|
||||
skipped += 1
|
||||
continue
|
||||
# Datum normalisieren: DD.MM.YYYY → YYYY-MM-DD
|
||||
if re.match(r"^\d{1,2}\.\d{1,2}\.\d{4}$", datum):
|
||||
d, m, y = datum.split(".")
|
||||
datum = f"{y}-{int(m):02d}-{int(d):02d}"
|
||||
|
||||
titel = (row.get("titel") or "").strip() or None
|
||||
text = (row.get("text") or "").strip() or None
|
||||
|
||||
raw_tags = (row.get("tags") or "").strip()
|
||||
if raw_tags.startswith("["):
|
||||
tags = json.loads(raw_tags)
|
||||
else:
|
||||
tags = [t.strip() for t in re.split(r"[;,]", raw_tags) if t.strip()]
|
||||
|
||||
lat = None
|
||||
lon = None
|
||||
try:
|
||||
lat_s = (row.get("gps_lat") or "").strip().replace(",", ".")
|
||||
lon_s = (row.get("gps_lon") or "").strip().replace(",", ".")
|
||||
if lat_s:
|
||||
lat = float(lat_s)
|
||||
if lon_s:
|
||||
lon = float(lon_s)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
is_milestone = (row.get("is_milestone") or "").strip().lower() in (
|
||||
"1", "true", "ja", "yes"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO diary (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
|
||||
VALUES (?, ?, 'eintrag', ?, ?, ?, ?, ?, ?)""",
|
||||
(dog_id, datum, titel, text, json.dumps(tags), lat, lon, int(is_milestone)),
|
||||
)
|
||||
entry_id = conn.execute(
|
||||
"SELECT last_insert_rowid() AS id"
|
||||
).fetchone()["id"]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
|
||||
(entry_id, dog_id),
|
||||
)
|
||||
imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Zeile {i}: {e}")
|
||||
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
|
@ -224,6 +224,40 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Health: Transponder-Zeile */
|
||||
.health-transponder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.health-transponder-label { color: var(--c-text-muted); }
|
||||
.health-transponder-edit { margin-left: auto; }
|
||||
|
||||
/* Import: Format-Auswahl-Karten */
|
||||
.import-format-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 2px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.import-format-card:hover { border-color: var(--c-primary-light); }
|
||||
.import-format-card--active { border-color: var(--c-primary); background: var(--c-primary-subtle); }
|
||||
.import-format-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--c-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
5. BADGES & STATUS-PILLS
|
||||
------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -408,6 +408,21 @@ const API = (() => {
|
|||
resetToken: () => del('/webcal/token'),
|
||||
};
|
||||
|
||||
const importData = {
|
||||
notestation(dogId, file) {
|
||||
const fd = new FormData();
|
||||
fd.append('dog_id', dogId);
|
||||
fd.append('file', file);
|
||||
return upload('/import/notestation', fd);
|
||||
},
|
||||
csv(dogId, file) {
|
||||
const fd = new FormData();
|
||||
fd.append('dog_id', dogId);
|
||||
fd.append('file', file);
|
||||
return upload('/import/csv', fd);
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ERROR-KLASSE
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -425,7 +440,7 @@ const API = (() => {
|
|||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||
friends, chat, webcal,
|
||||
friends, chat, webcal, importData,
|
||||
subscribeToPush, getLocation,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -135,12 +135,20 @@ window.Page_diary = (() => {
|
|||
// ----------------------------------------------------------
|
||||
async function _renderDiary() {
|
||||
_container.innerHTML = `
|
||||
<div class="by-toolbar diary-toolbar">
|
||||
<button class="btn btn-secondary btn-sm" id="diary-import-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
|
||||
Importieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="diary-list"></div>
|
||||
<div id="diary-load-more" style="display:none; text-align:center; padding:var(--space-4)">
|
||||
<button class="btn btn-secondary" id="diary-btn-more">Weitere laden</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_container.querySelector('#diary-import-btn')
|
||||
?.addEventListener('click', _showImport);
|
||||
_container.querySelector('#diary-btn-more')
|
||||
?.addEventListener('click', () => _loadMore());
|
||||
|
||||
|
|
@ -547,6 +555,125 @@ window.Page_diary = (() => {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// IMPORT
|
||||
// ----------------------------------------------------------
|
||||
function _showImport() {
|
||||
UI.modal.open({
|
||||
title: 'Tagebuch importieren',
|
||||
body: `
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-4)">
|
||||
Importiere Einträge aus einer anderen App in das Tagebuch von
|
||||
<strong>${_escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
|
||||
</p>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
|
||||
<label class="import-format-card" id="fmt-nsx">
|
||||
<input type="radio" name="import-fmt" value="nsx" checked style="display:none">
|
||||
<div class="import-format-icon">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="import-format-card" id="fmt-csv">
|
||||
<input type="radio" name="import-fmt" value="csv" style="display:none">
|
||||
<div class="import-format-icon">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:var(--weight-semibold)">CSV / Excel</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:var(--space-4)">
|
||||
<label class="form-label">Datei auswählen</label>
|
||||
<input type="file" class="form-control" id="import-file-input"
|
||||
accept=".nsx,.csv" style="cursor:pointer">
|
||||
</div>
|
||||
|
||||
<div id="import-result" style="display:none;margin-top:var(--space-4)"></div>`,
|
||||
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-primary" id="import-start-btn">Importieren</button>`,
|
||||
});
|
||||
|
||||
// Format-Karten klickbar machen
|
||||
document.querySelectorAll('.import-format-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
document.querySelectorAll('.import-format-card').forEach(c => c.classList.remove('import-format-card--active'));
|
||||
card.classList.add('import-format-card--active');
|
||||
card.querySelector('input[type=radio]').checked = true;
|
||||
// Accept-Attribut anpassen
|
||||
const fmt = card.querySelector('input').value;
|
||||
document.getElementById('import-file-input').accept = fmt === 'nsx' ? '.nsx' : '.csv';
|
||||
});
|
||||
});
|
||||
// Erste Karte direkt aktiv setzen
|
||||
document.getElementById('fmt-nsx')?.classList.add('import-format-card--active');
|
||||
|
||||
document.getElementById('import-start-btn').addEventListener('click', async () => {
|
||||
const fileInput = document.getElementById('import-file-input');
|
||||
const fmt = document.querySelector('input[name="import-fmt"]:checked')?.value;
|
||||
const btn = document.getElementById('import-start-btn');
|
||||
const resultEl = document.getElementById('import-result');
|
||||
|
||||
if (!fileInput.files.length) {
|
||||
UI.toast('Bitte zuerst eine Datei auswählen.', 'warning');
|
||||
return;
|
||||
}
|
||||
const file = fileInput.files[0];
|
||||
const dogId = _appState.activeDog?.id;
|
||||
|
||||
UI.setLoading(btn, true);
|
||||
resultEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = fmt === 'nsx'
|
||||
? await API.importData.notestation(dogId, file)
|
||||
: await API.importData.csv(dogId, file);
|
||||
|
||||
const errHtml = res.errors?.length
|
||||
? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
|
||||
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${_escape(res.errors.join('\n'))}</pre></details>`
|
||||
: '';
|
||||
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
|
||||
padding:var(--space-3) var(--space-4);color:var(--c-success)">
|
||||
<strong>${res.imported} Einträge importiert</strong>
|
||||
${res.skipped ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
|
||||
${errHtml}
|
||||
</div>`;
|
||||
resultEl.style.display = 'block';
|
||||
UI.setLoading(btn, false);
|
||||
|
||||
// Diary neu laden falls etwas importiert wurde
|
||||
if (res.imported > 0) {
|
||||
_offset = 0;
|
||||
_entries = [];
|
||||
await _load();
|
||||
_renderList();
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--c-danger-subtle);border-radius:var(--radius-md);
|
||||
padding:var(--space-3) var(--space-4);color:var(--c-danger)">
|
||||
Fehler: ${_escape(e.message || String(e))}
|
||||
</div>`;
|
||||
resultEl.style.display = 'block';
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -125,12 +125,26 @@ window.Page_health = (() => {
|
|||
// HEALTH-ANSICHT — Tabs mit Einträgen
|
||||
// ----------------------------------------------------------
|
||||
async function _renderHealth() {
|
||||
const dog = _appState.activeDog;
|
||||
const transponderHtml = `
|
||||
<div class="health-transponder" id="health-transponder">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg>
|
||||
<span class="health-transponder-label">Transponder:</span>
|
||||
<span class="health-transponder-nr" id="health-transponder-nr">
|
||||
${dog?.chip_nr ? `<strong>${_esc(dog.chip_nr)}</strong>` : '<em style="color:var(--c-text-muted)">nicht eingetragen</em>'}
|
||||
</span>
|
||||
<button class="btn btn-link btn-sm health-transponder-edit" id="health-transponder-edit"
|
||||
style="padding:0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
_container.innerHTML = `
|
||||
<div class="by-toolbar health-header">
|
||||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||
${UI.icon('star')} KI-Zusammenfassung
|
||||
</button>
|
||||
</div>
|
||||
${transponderHtml}
|
||||
<div id="health-reminders"></div>
|
||||
<div class="by-tabs" id="by-tabs"></div>
|
||||
<div id="by-tab-content"></div>
|
||||
|
|
@ -139,6 +153,8 @@ window.Page_health = (() => {
|
|||
_renderTabBar();
|
||||
_container.querySelector('#health-ki-btn')
|
||||
.addEventListener('click', _showKiSummary);
|
||||
_container.querySelector('#health-transponder-edit')
|
||||
.addEventListener('click', () => _editTransponder(dog));
|
||||
|
||||
await _loadAll();
|
||||
_renderErinnerungen();
|
||||
|
|
@ -1519,6 +1535,42 @@ window.Page_health = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TRANSPONDER-BEARBEITUNG
|
||||
// ----------------------------------------------------------
|
||||
async function _editTransponder(dog) {
|
||||
const currentNr = dog?.chip_nr || '';
|
||||
UI.modal.open({
|
||||
title: 'Transpondernummer',
|
||||
body: `
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Chip-Nummer (15-stellig)</label>
|
||||
<input id="transponder-input" class="form-control" type="text"
|
||||
value="${_esc(currentNr)}" placeholder="z.B. 276009200123456" maxlength="20">
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="transponder-save-btn">Speichern</button>`,
|
||||
});
|
||||
document.getElementById('transponder-save-btn').addEventListener('click', async () => {
|
||||
const nr = document.getElementById('transponder-input').value.trim() || null;
|
||||
const btn = document.getElementById('transponder-save-btn');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
await API.dogs.update(dog.id, { chip_nr: nr });
|
||||
_appState.activeDog.chip_nr = nr;
|
||||
UI.modal.close();
|
||||
const nrEl = _container.querySelector('#health-transponder-nr');
|
||||
if (nrEl) nrEl.innerHTML = nr
|
||||
? `<strong>${_esc(nr)}</strong>`
|
||||
: '<em style="color:var(--c-text-muted)">nicht eingetragen</em>';
|
||||
} catch (e) {
|
||||
UI.setLoading(btn, false);
|
||||
UI.toast('Fehler beim Speichern', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI-ZUSAMMENFASSUNG
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v141';
|
||||
const CACHE_VERSION = 'by-v142';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue