Compare commits

...

2 commits

21 changed files with 295 additions and 786 deletions

View file

@ -1 +1 @@
1131 1133

View file

@ -15,7 +15,9 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
# Erlaubte Typen # Erlaubte Typen
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"} # Routine-/Pflege-Typen (wiederkehrend mit intervall_tage): parasit, krallen, fellpflege
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument",
"laeufigkeit", "parasit", "krallen", "fellpflege"}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -164,15 +166,15 @@ async def create_health(dog_id: int, data: HealthCreate,
(dog_id, typ, bezeichnung, datum, naechstes, notiz, (dog_id, typ, bezeichnung, datum, naechstes, notiz,
wert, einheit, charge_nr, tierarzt_name, kosten, diagnose, wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
dosierung, haeufigkeit, aktiv, bis_datum, dosierung, haeufigkeit, aktiv, bis_datum,
schweregrad, reaktion, erinnerung, tierarzt_id, schweregrad, reaktion, erinnerung, intervall_tage, tierarzt_id,
deckdatum, wurftermin) deckdatum, wurftermin)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes, (dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes,
data.notiz, data.wert, data.einheit, data.charge_nr, data.notiz, data.wert, data.einheit, data.charge_nr,
data.tierarzt_name, data.kosten, data.diagnose, data.dosierung, data.tierarzt_name, data.kosten, data.diagnose, data.dosierung,
data.haeufigkeit, data.aktiv, data.bis_datum, data.haeufigkeit, data.aktiv, data.bis_datum,
data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id, data.schweregrad, data.reaktion, data.erinnerung, data.intervall_tage,
data.deckdatum, data.wurftermin) data.tierarzt_id, data.deckdatum, data.wurftermin)
) )
row = conn.execute( row = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
@ -212,6 +214,34 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
return _entry_with_media(row, media_map) return _entry_with_media(row, media_map)
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/{id}/erledigt
# Markiert eine wiederkehrende Routine als heute erledigt und schreibt
# bei gesetztem intervall_tage das nächste Fälligkeitsdatum automatisch fort.
# ------------------------------------------------------------------
@router.post("/{dog_id}/health/{entry_id}/erledigt")
async def complete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)):
from datetime import timedelta
today = date.today()
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute(
"SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
intervall = entry["intervall_tage"]
naechstes = (today + timedelta(days=intervall)).isoformat() if intervall else None
conn.execute(
"UPDATE health SET datum=?, naechstes=? WHERE id=?",
(today.isoformat(), naechstes, entry_id),
)
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
media_map = _fetch_media_items(conn, [entry_id])
return _entry_with_media(row, media_map)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# DELETE /api/dogs/{dog_id}/health/{id} # DELETE /api/dogs/{dog_id}/health/{id}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -500,6 +530,9 @@ _TERMIN_TYPEN = {
'tierarzt': {'label': 'Tierarztbesuch','beim_tierarzt': True, 'icon': 'first-aid'}, 'tierarzt': {'label': 'Tierarztbesuch','beim_tierarzt': True, 'icon': 'first-aid'},
'medikament': {'label': 'Medikament', 'beim_tierarzt': False, 'icon': 'pill'}, 'medikament': {'label': 'Medikament', 'beim_tierarzt': False, 'icon': 'pill'},
'laeufigkeit': {'label': 'Läufigkeit', 'beim_tierarzt': False, 'icon': 'calendar'}, 'laeufigkeit': {'label': 'Läufigkeit', 'beim_tierarzt': False, 'icon': 'calendar'},
'parasit': {'label': 'Zecken-/Flohschutz', 'beim_tierarzt': False, 'icon': 'bug-beetle'},
'krallen': {'label': 'Krallen schneiden', 'beim_tierarzt': False, 'icon': 'scissors'},
'fellpflege': {'label': 'Fellpflege', 'beim_tierarzt': False, 'icon': 'wind'},
} }
@router.get("/{dog_id}/health/terminvorschlaege") @router.get("/{dog_id}/health/terminvorschlaege")

View file

@ -268,12 +268,18 @@ async def update_application(
"UPDATE users SET is_social_media=1 WHERE id=?", "UPDATE users SET is_social_media=1 WHERE id=?",
(row["user_id"],) (row["user_id"],)
) )
founder_count = conn.execute( # Atomare Gründer-Vergabe inkl. founder_number — Race-frei via Sub-Query
"SELECT COUNT(*) FROM users WHERE is_founder=1" # (konsistent mit dogs.py / partner.py).
).fetchone()[0]
if founder_count < 100:
conn.execute( conn.execute(
"UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0", """UPDATE users
SET is_founder = 1,
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
)
WHERE id = ?
AND (is_founder IS NULL OR is_founder = 0)
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""",
(row["user_id"],) (row["user_id"],)
) )

View file

@ -638,7 +638,8 @@ async def _job_health_reminders():
FROM health h FROM health h
JOIN dogs d ON d.id = h.dog_id JOIN dogs d ON d.id = h.dog_id
WHERE h.naechstes IN (?, ?, ?, ?) WHERE h.naechstes IN (?, ?, ?, ?)
AND h.typ IN ('impfung', 'entwurmung', 'medikament') AND h.typ IN ('impfung', 'entwurmung', 'medikament',
'parasit', 'krallen', 'fellpflege')
AND (h.erinnerung IS NULL OR h.erinnerung = 1) AND (h.erinnerung IS NULL OR h.erinnerung = 1)
""", (str(today), str(in7), str(in3), str(yesterday))).fetchall() """, (str(today), str(in7), str(in3), str(yesterday))).fetchall()

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1131"></script> <script src="/js/boot-early.js?v=1133"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1131"> <link rel="stylesheet" href="/css/design-system.css?v=1133">
<link rel="stylesheet" href="/css/layout.css?v=1131"> <link rel="stylesheet" href="/css/layout.css?v=1133">
<link rel="stylesheet" href="/css/components.css?v=1131"> <link rel="stylesheet" href="/css/components.css?v=1133">
<link rel="stylesheet" href="/css/utilities.css?v=1131"> <link rel="stylesheet" href="/css/utilities.css?v=1133">
<link rel="stylesheet" href="/css/lists.css?v=1131"> <link rel="stylesheet" href="/css/lists.css?v=1133">
</head> </head>
<body> <body>
@ -617,11 +617,11 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1131"></script> <script src="/js/api.js?v=1133"></script>
<script src="/js/ui.js?v=1131"></script> <script src="/js/ui.js?v=1133"></script>
<script src="/js/app.js?v=1131"></script> <script src="/js/app.js?v=1133"></script>
<script src="/js/worlds.js?v=1131"></script> <script src="/js/worlds.js?v=1133"></script>
<script src="/js/offline-indicator.js?v=1131"></script> <script src="/js/offline-indicator.js?v=1133"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1131"></script> <script src="/js/boot.js?v=1133"></script>
</body> </body>

View file

@ -204,6 +204,7 @@ const API = (() => {
}, },
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); }, create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); }, update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
complete(dogId, id) { return post(`/dogs/${dogId}/health/${id}/erledigt`); },
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); }, delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
uploadDokument(dogId, id, formData) { uploadDokument(dogId, id, formData) {
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData); return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1131'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '1133'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -782,7 +782,7 @@ window.Page_diary = (() => {
const id = parseInt(btn.dataset.entryId); const id = parseInt(btn.dataset.entryId);
const label = btn.dataset.label || ''; const label = btn.dataset.label || '';
const location = btn.dataset.location || null; const location = btn.dataset.location || null;
_openNoteModal('diary', id, label, location || null); UI.noteModal('diary', id, label, location || null);
}); });
}); });
} }
@ -1190,7 +1190,7 @@ window.Page_diary = (() => {
view.querySelector('#diary-dv-note')?.addEventListener('click', e => { view.querySelector('#diary-dv-note')?.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const label = entry.titel || entry.datum || String(entry.id); const label = entry.titel || entry.datum || String(entry.id);
_openNoteModal('diary', entry.id, label, entry.location_name || null); UI.noteModal('diary', entry.id, label, entry.location_name || null);
}); });
// Bearbeiten // Bearbeiten
@ -1953,83 +1953,6 @@ window.Page_diary = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC

View file

@ -461,7 +461,7 @@ window.Page_erste_hilfe = (() => {
const titel = btn.dataset.titel; const titel = btn.dataset.titel;
const kat = KATEGORIEN.find(k => k.id === katId); const kat = KATEGORIEN.find(k => k.id === katId);
const label = kat ? `${kat.label}${titel}` : titel; const label = kat ? `${kat.label}${titel}` : titel;
_openNoteModal('erste_hilfe', katId, label, null); UI.noteModal('erste_hilfe', katId, label, null);
}); });
}); });
} }
@ -469,85 +469,6 @@ window.Page_erste_hilfe = (() => {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------------- // ----------------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
const _esc = s => s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// PUBLIC // PUBLIC

View file

@ -643,7 +643,7 @@ window.Page_events = (() => {
const noteBtn = e.target.closest('.ev-note-btn'); const noteBtn = e.target.closest('.ev-note-btn');
if (noteBtn) { if (noteBtn) {
e.stopPropagation(); e.stopPropagation();
_openNoteModal( UI.noteModal(
'event', 'event',
parseInt(noteBtn.dataset.evNoteId), parseInt(noteBtn.dataset.evNoteId),
noteBtn.dataset.evNoteLabel, noteBtn.dataset.evNoteLabel,
@ -660,55 +660,6 @@ window.Page_events = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="ev-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Event…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="ev-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="ev-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#ev-note-close')?.addEventListener('click', close);
ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#ev-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, openNew, _openDetail: _showDetail }; return { init, refresh, openNew, _openDetail: _showDetail };

View file

@ -442,7 +442,7 @@ window.Page_friends = (() => {
e.stopPropagation(); e.stopPropagation();
const id = parseInt(btn.dataset.frNoteId); const id = parseInt(btn.dataset.frNoteId);
const name = btn.dataset.frNoteName || ''; const name = btn.dataset.frNoteName || '';
_openNoteModal('friends', id, name, null); UI.noteModal('friends', id, name, null);
}); });
}); });
@ -866,83 +866,6 @@ window.Page_friends = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// NOTIZ-MODAL // NOTIZ-MODAL
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat }; return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };

View file

@ -19,6 +19,7 @@ window.Page_health = (() => {
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' }, { key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' }, { key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
{ key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' }, { key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'pflege', label: 'Pflege', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>' },
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' }, { key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' }, { key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' }, { key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
@ -27,6 +28,14 @@ window.Page_health = (() => {
]; ];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' }; const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
// Pflege-Routinen — wiederkehrende Pflege-Aufgaben, gebündelt im 'pflege'-Tab
const PFLEGE_TYPEN = ['parasit', 'krallen', 'fellpflege'];
const PFLEGE_META = {
parasit: { label: 'Zecken-/Flohschutz', icon: 'bug-beetle', placeholder: 'z.B. Frontline, Seresto-Halsband' },
krallen: { label: 'Krallen schneiden', icon: 'scissors', placeholder: 'z.B. Krallen kürzen' },
fellpflege: { label: 'Fellpflege', icon: 'wind', placeholder: 'z.B. Bürsten, Trimmen, Baden' },
};
function _getTabs() { function _getTabs() {
const tabs = [...BASE_TABS]; const tabs = [...BASE_TABS];
if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB); if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB);
@ -290,6 +299,8 @@ window.Page_health = (() => {
_data = {}; _data = {};
_getTabs().forEach(t => { _data[t.key] = []; }); _getTabs().forEach(t => { _data[t.key] = []; });
_data['laeufigkeit'] = _data['laeufigkeit'] || []; _data['laeufigkeit'] = _data['laeufigkeit'] || [];
// Pflege-Routinen: eigene Listen je Typ (Tab 'pflege' bündelt sie beim Rendern)
PFLEGE_TYPEN.forEach(t => { _data[t] = []; });
all.forEach(e => { all.forEach(e => {
if (_data[e.typ] !== undefined) _data[e.typ].push(e); if (_data[e.typ] !== undefined) _data[e.typ].push(e);
}); });
@ -333,6 +344,7 @@ window.Page_health = (() => {
case 'gewicht': content.innerHTML = _renderGewicht(entries); break; case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break; case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break; case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'pflege': content.innerHTML = _renderPflege(); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break; case 'praxen': content.innerHTML = _renderPraxen(); break;
@ -410,6 +422,65 @@ window.Page_health = (() => {
return { color: 'green', label: 'Aktuell', icon: '🟢' }; return { color: 'green', label: 'Aktuell', icon: '🟢' };
} }
// ----------------------------------------------------------
// PFLEGE-ROUTINEN (Zecken-/Flohschutz, Krallen, Fellpflege)
// ----------------------------------------------------------
function _intervallLabel(tage) {
if (!tage) return '';
const m = { 30: 'monatlich', 60: 'alle 2 Monate', 90: 'vierteljährlich', 180: 'halbjährlich', 365: 'jährlich' };
return m[tage] || `alle ${tage} Tage`;
}
function _renderPflege() {
const addButtons = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
${PFLEGE_TYPEN.map(t => `
<button class="btn btn-secondary btn-sm" data-action="add-routine" data-typ="${t}">
${UI.icon(PFLEGE_META[t].icon)} ${PFLEGE_META[t].label}
</button>`).join('')}
</div>`;
const all = PFLEGE_TYPEN.flatMap(t => (_data[t] || []).map(e => ({ ...e, _typ: t })));
if (!all.length) return addButtons + _emptyState(
'paw-print',
'Noch keine Pflege-Routinen',
'Lege wiederkehrende Routinen wie Zecken-/Flohschutz, Krallenschneiden oder Fellpflege an — wir erinnern dich rechtzeitig.'
);
// Fällige zuerst (nach naechstes), Einträge ohne Folgedatum ans Ende
all.sort((a, b) => {
if (!a.naechstes) return 1;
if (!b.naechstes) return -1;
return a.naechstes.localeCompare(b.naechstes);
});
const items = all.map(e => {
const meta = PFLEGE_META[e._typ];
const ampel = e.naechstes ? _impfAmpel(e.naechstes) : null;
const interv = _intervallLabel(e.intervall_tage);
return `
<div class="list-item-card list-item-card--clickable health-card" data-id="${e.id}" data-action="open-entry">
${ampel ? `<div class="health-card-ampel ampel-${ampel.color}" title="${ampel.label}"></div>` : ''}
<div class="list-item-body">
<div class="list-item-title">${UI.icon(meta.icon)} ${UI.escape(e.bezeichnung || meta.label)}</div>
<div class="list-item-meta-row">
${meta.label}${e.datum ? ` · zuletzt ${UI.time.format(e.datum + 'T00:00:00')}` : ''}${interv ? ` · ${interv}` : ''}
</div>
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
Nächste: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
</div>
<button class="btn btn-sm btn-primary" data-action="routine-erledigt" data-id="${e.id}"
style="flex-shrink:0;white-space:nowrap">
${UI.icon('check')} Erledigt
</button>
</div>`;
}).join('');
return addButtons + `<div class="health-list">${items}</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// TIERARZTBESUCHE // TIERARZTBESUCHE
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -883,21 +954,51 @@ window.Page_health = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// EVENTS BINDEN // EVENTS BINDEN
// ---------------------------------------------------------- // ----------------------------------------------------------
// Sucht einen Eintrag in der/den Liste(n) des aktiven Tabs.
// Im Pflege-Tab sind die Einträge auf mehrere Typ-Listen verteilt.
function _entriesForActiveTab() {
if (_activeTab === 'pflege') return PFLEGE_TYPEN.flatMap(t => _data[t] || []);
return _data[_activeTab] || [];
}
function _bindTabEvents(content) { function _bindTabEvents(content) {
content.querySelectorAll('[data-action="add-entry"]').forEach(btn => { content.querySelectorAll('[data-action="add-entry"]').forEach(btn => {
btn.addEventListener('click', () => _showForm(null, _activeTab)); btn.addEventListener('click', () => _showForm(null, _activeTab));
}); });
// Pflege: pro-Typ-Button "+ Routine" → Formular mit festem Typ
content.querySelectorAll('[data-action="add-routine"]').forEach(btn => {
btn.addEventListener('click', () => _showForm(null, btn.dataset.typ));
});
// Pflege: Routine als erledigt markieren → Backend schreibt naechstes fort
content.querySelectorAll('[data-action="routine-erledigt"]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
await UI.asyncButton(btn, async () => {
const saved = await API.health.complete(_appState.activeDog.id, id);
const list = _data[saved.typ];
if (list) {
const idx = list.findIndex(x => x.id === id);
if (idx !== -1) list[idx] = saved;
}
_renderTab();
_renderErinnerungen();
UI.toast.success('Als erledigt eingetragen.');
});
});
});
content.querySelectorAll('[data-action="open-entry"]').forEach(card => { content.querySelectorAll('[data-action="open-entry"]').forEach(card => {
const id = parseInt(card.dataset.id); const id = parseInt(card.dataset.id);
const entry = (_data[_activeTab] || []).find(e => e.id === id); const entry = _entriesForActiveTab().find(e => e.id === id);
if (entry) card.addEventListener('click', () => _openDetail(entry)); if (entry) card.addEventListener('click', () =>
_activeTab === 'pflege' ? _showForm(entry, entry.typ) : _openDetail(entry));
}); });
content.querySelectorAll('[data-action="open-note"]').forEach(btn => { content.querySelectorAll('[data-action="open-note"]').forEach(btn => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const id = parseInt(btn.dataset.entryId); const id = parseInt(btn.dataset.entryId);
const label = btn.dataset.label || ''; const label = btn.dataset.label || '';
_openNoteModal('health', id, label, null); UI.noteModal('health', id, label, null);
}); });
}); });
// Praxis öffnen → Detail-Modal mit Bewertungen // Praxis öffnen → Detail-Modal mit Bewertungen
@ -980,8 +1081,19 @@ window.Page_health = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// DETAIL-ANSICHT // DETAIL-ANSICHT
// ---------------------------------------------------------- // ----------------------------------------------------------
// Tab-Info (Icon + Label) für einen Typ — kennt auch die Pflege-Routine-Typen,
// die keinen eigenen Tab haben (sie liegen im gebündelten 'pflege'-Tab).
function _typInfo(typ) {
const meta = PFLEGE_META[typ];
if (meta) return {
icon: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${meta.icon}"></use></svg>`,
label: meta.label,
};
return _getTabs().find(t => t.key === typ) || BASE_TABS[0];
}
function _openDetail(entry) { function _openDetail(entry) {
const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0]; const tabInfo = _typInfo(entry.typ);
const fields = _detailFields(entry); const fields = _detailFields(entry);
// Media-Items zusammenstellen (neue + legacy) // Media-Items zusammenstellen (neue + legacy)
@ -1151,7 +1263,7 @@ window.Page_health = (() => {
</div> </div>
`; `;
const tabInfo = _getTabs().find(tab => tab.key === t) || BASE_TABS[0]; const tabInfo = _typInfo(t);
UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body, footer }); UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body, footer });
const form = document.getElementById('health-form'); const form = document.getElementById('health-form');
@ -1294,6 +1406,9 @@ window.Page_health = (() => {
allergie: 'z.B. Hühnchen, Gras, Hausstaub', allergie: 'z.B. Hühnchen, Gras, Hausstaub',
dokument: 'z.B. Impfpass, Blutbild', dokument: 'z.B. Impfpass, Blutbild',
laeufigkeit: 'Läufigkeit', laeufigkeit: 'Läufigkeit',
parasit: 'z.B. Frontline, Seresto-Halsband',
krallen: 'z.B. Krallen kürzen',
fellpflege: 'z.B. Bürsten, Trimmen, Baden',
}; };
return ph[typ] || ''; return ph[typ] || '';
} }
@ -1363,6 +1478,17 @@ window.Page_health = (() => {
</div> </div>
${_praxisSelectField(entry)} ${_praxisSelectField(entry)}
`; `;
case 'parasit':
case 'krallen':
case 'fellpflege': return `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Nächste Fälligkeit</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
${_intervallField(entry)}
</div>
`;
case 'tierarzt': { case 'tierarzt': {
const aktivePraxen = _praxen.filter(p => p.aktiv); const aktivePraxen = _praxen.filter(p => p.aktiv);
const praxisField = aktivePraxen.length const praxisField = aktivePraxen.length
@ -2849,85 +2975,6 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
// ---------------------------------------------------------- // ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
// Vorhandenes Modal entfernen falls noch offen
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
// Vorhandene Notiz laden
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// KI-TIERARZTFRAGEN // KI-TIERARZTFRAGEN

View file

@ -353,7 +353,7 @@ window.Page_lost = (() => {
e.stopPropagation(); e.stopPropagation();
const id = parseInt(btn.dataset.lostNoteId); const id = parseInt(btn.dataset.lostNoteId);
const name = btn.dataset.lostNoteName || ''; const name = btn.dataset.lostNoteName || '';
_openNoteModal('lost', id, name, null); UI.noteModal('lost', id, name, null);
}); });
}); });
} }
@ -804,83 +804,6 @@ function _emptyState(icon, title, text, cta = '') {
// ---------------------------------------------------------- // ----------------------------------------------------------
// NOTIZ-MODAL // NOTIZ-MODAL
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC

View file

@ -257,7 +257,7 @@ window.Page_poison = (() => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const id = parseInt(btn.dataset.poisonNoteId); const id = parseInt(btn.dataset.poisonNoteId);
_openNoteModal('poison', id, 'Giftköder-Meldung ' + id, null); UI.noteModal('poison', id, 'Giftköder-Meldung ' + id, null);
}); });
}); });
} }
@ -650,83 +650,6 @@ window.Page_poison = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// NOTIZ-MODAL // NOTIZ-MODAL
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC

View file

@ -2397,7 +2397,7 @@ window.Page_routes = (() => {
// Notiz-Button // Notiz-Button
document.getElementById('rd-note')?.addEventListener('click', () => { document.getElementById('rd-note')?.addEventListener('click', () => {
const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route'); const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route');
_openNoteModal('route', route.id, label, null); UI.noteModal('route', route.id, label, null);
}); });
// Mini-Map // Mini-Map
@ -3054,55 +3054,6 @@ window.Page_routes = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="rk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="rk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu dieser Route…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="rk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="rk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#rk-note-close')?.addEventListener('click', close);
ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#rk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

View file

@ -714,7 +714,7 @@ window.Page_sitting = (() => {
const noteBtn = e.target.closest('.sit-note-btn'); const noteBtn = e.target.closest('.sit-note-btn');
if (noteBtn) { if (noteBtn) {
e.stopPropagation(); e.stopPropagation();
_openNoteModal( UI.noteModal(
'sitting', 'sitting',
parseInt(noteBtn.dataset.sitNoteId), parseInt(noteBtn.dataset.sitNoteId),
noteBtn.dataset.sitNoteLabel, noteBtn.dataset.sitNoteLabel,
@ -763,55 +763,6 @@ window.Page_sitting = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="sit-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="sit-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Sitter…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="sit-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="sit-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#sit-note-close')?.addEventListener('click', close);
ovl.querySelector('#sit-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#sit-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#sit-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh }; return { init, refresh };

View file

@ -538,7 +538,7 @@ function _icon(name) {
const planLabel = _activePlan === 'welpe' ? 'Welpe 06 Monate' const planLabel = _activePlan === 'welpe' ? 'Welpe 06 Monate'
: _activePlan === 'junior' ? 'Junior 618 Monate' : _activePlan === 'junior' ? 'Junior 618 Monate'
: `Erwachsener Hund ${_activeAdultTab}`; : `Erwachsener Hund ${_activeAdultTab}`;
_openNoteModal('trainingsplan', dogId, planLabel, null); UI.noteModal('trainingsplan', dogId, planLabel, null);
}); });
// Plan selector // Plan selector
@ -768,84 +768,6 @@ function _icon(name) {
// ---------------------------------------------------------- // ----------------------------------------------------------
// NOTIZ-MODAL // NOTIZ-MODAL
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
// Vorhandenes Modal entfernen falls noch offen
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC API // PUBLIC API

View file

@ -311,7 +311,7 @@ window.Page_walks = (() => {
el.querySelectorAll('.wk-note-btn').forEach(btn => { el.querySelectorAll('.wk-note-btn').forEach(btn => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
_openNoteModal( UI.noteModal(
'walk', 'walk',
parseInt(btn.dataset.wkNoteId), parseInt(btn.dataset.wkNoteId),
btn.dataset.wkNoteLabel, btn.dataset.wkNoteLabel,
@ -1211,55 +1211,6 @@ window.Page_walks = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="wk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="wk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Gassi-Treffen…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="wk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="wk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#wk-note-close')?.addEventListener('click', close);
ovl.querySelector('#wk-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#wk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#wk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
// ============================================================== // ==============================================================
// FEATURE 1: Foto-Challenge der Woche // FEATURE 1: Foto-Challenge der Woche

View file

@ -1327,9 +1327,91 @@ const UI = (() => {
}); });
} }
// ----------------------------------------------------------
// NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId)
// erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat.
// ----------------------------------------------------------
async function noteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)">${_svgIcon('note-pencil')} Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
}
toast.success('Notiz gespeichert.');
_close();
} catch (err) {
toast.error(err.message || 'Fehler beim Speichern.');
setLoading(saveBtn, false);
}
});
}
// Öffentliche API // Öffentliche API
return { return {
toast, modal, toast, modal,
noteModal,
setLoading, asyncButton, setLoading, asyncButton,
formData, setFormError, clearFormErrors, formData, setFormError, clearFormErrors,
emptyState, errorState, time, text, money, emptyState, errorState, time, text, money,

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1131"></script> <script src="/js/landing-init.js?v=1133"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title> <title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz"> <meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1131'; const VER = '1133';
const CACHE_VERSION = `by-v${VER}`; const CACHE_VERSION = `by-v${VER}`;
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