Feature: Gesundheit Multi-Media — health_media Tabelle + Multi-Upload UI

- Neue Tabelle health_media (Migration in database.py) analog zu diary_media
- GET-Endpoints geben media_items:[{id,url,media_type}] zurück (datei_url bleibt für Rückwärtskompatibilität)
- POST /health/{id}/media und DELETE /health/{id}/media/{media_id} Endpoints
- Multi-Upload-Bereich im Formular: Thumbnails für Bilder, PDF-Icon, X-Button zum Entfernen
- Galerie in Detailansicht: Bilder klickbar/zoombar, PDFs als Link
- CSS-Klassen health-media-grid/thumb/gallery in components.css
- SW by-v213, APP_VER 187
This commit is contained in:
rene 2026-04-18 19:09:39 +02:00
parent fa0fcbf8c9
commit aa70a838f2
5 changed files with 347 additions and 42 deletions

View file

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

View file

@ -813,30 +813,42 @@ window.Page_health = (() => {
});
const items = entries.map(e => {
const isPdf = e.datei_typ === 'pdf';
const hasFile = !!e.datei_url;
// media_items bevorzugen, legacy datei_url als Fallback
const mediaList = e.media_items?.length
? e.media_items
: (e.datei_url ? [{ id: null, url: e.datei_url, media_type: e.datei_typ || 'image' }] : []);
const firstImg = mediaList.find(m => m.media_type !== 'pdf');
const hasPdf = mediaList.some(m => m.media_type === 'pdf');
const count = mediaList.length;
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
${hasFile && !isPdf
? `<img src="${e.datei_url}" class="health-doc-thumb" alt="Vorschau"
${firstImg
? `<img src="${_esc(firstImg.url)}" class="health-doc-thumb" alt="Vorschau"
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${count > 1 ? ` · ${count} Dateien` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
${hasFile
${count
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
<a href="${e.datei_url}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
</a>
<button class="btn btn-danger btn-sm" data-action="delete-dok" data-id="${e.id}"
onclick="event.stopPropagation()" aria-label="Dokument löschen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF
</a>`
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild
</a>`
).join('')}
</div>`
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
</div>
@ -902,14 +914,29 @@ window.Page_health = (() => {
const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0];
const fields = _detailFields(entry);
// Media-Items zusammenstellen (neue + legacy)
const mediaItems = entry.media_items?.length
? entry.media_items
: (entry.datei_url ? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image' }] : []);
const mediaHtml = mediaItems.length
? `<div class="health-media-gallery" style="margin-top:var(--space-4)">
${mediaItems.map(m => m.media_type === 'pdf'
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm health-media-gallery-pdf">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen
</a>`
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener" class="health-media-gallery-img">
<img src="${_esc(m.url)}" alt="Bild" loading="lazy">
</a>`
).join('')}
</div>`
: '';
const body = `
<div class="health-detail">
${fields}
${entry.datei_url
? (entry.datei_typ === 'pdf'
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen</a>`
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''}
${mediaHtml}
</div>
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
`;
@ -989,15 +1016,42 @@ window.Page_health = (() => {
</div>
`;
const uploadField = t === 'dokument' ? `
<div class="form-group">
// Multi-Upload-Bereich — zeige vorhandene media_items + neuen Upload
const existingMedia = (entry?.media_items || []);
const legacyFile = (!existingMedia.length && entry?.datei_url)
? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image', _legacy: true }]
: [];
const allMedia = [...existingMedia, ...legacyFile];
const mediaThumbsHtml = allMedia.map(m => {
const isImg = m.media_type !== 'pdf';
const removeBtn = m.id
? `<button type="button" class="health-media-remove" data-media-id="${m.id}"
title="Entfernen" aria-label="Datei entfernen">×</button>`
: '';
return `<div class="health-media-thumb" data-media-id="${m.id || ''}">
${isImg
? `<img src="${_esc(m.url)}" alt="Vorschau">`
: `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div>`}
${removeBtn}
</div>`;
}).join('');
const uploadField = `
<div class="form-group" id="health-media-section">
<label class="form-label">
Datei (JPG, PNG, PDF)
${UI.help('PDF oder Foto — z.B. Impfpass, Röntgenbild, Befund.')}
Dateien (Bilder / PDFs)
${UI.help('Befunde, Röntgenbilder, Laborwerte — mehrere Dateien möglich.')}
</label>
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
<div class="health-media-grid" id="health-media-grid">${mediaThumbsHtml}</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei hinzufügen
<input type="file" name="datei_neu" accept="image/*,.pdf" multiple
style="position:absolute;opacity:0;width:1px;height:1px" id="health-file-input">
</label>
<div id="health-file-pending" style="margin-top:var(--space-2);display:flex;flex-wrap:wrap;gap:var(--space-2)"></div>
</div>
` : '';
`;
const body = `
<form id="health-form" autocomplete="off">
@ -1030,6 +1084,48 @@ window.Page_health = (() => {
_activeTab = 'praxen';
_renderTab();
});
// File-Input: Vorschau für ausstehende Uploads
const fileInput = document.getElementById('health-file-input');
const pendingBox = document.getElementById('health-file-pending');
if (fileInput && pendingBox) {
fileInput.addEventListener('change', () => {
pendingBox.innerHTML = '';
Array.from(fileInput.files || []).forEach(f => {
const isPdf = f.name.toLowerCase().endsWith('.pdf');
const thumb = document.createElement('div');
thumb.className = 'health-media-thumb health-media-thumb--pending';
if (isPdf) {
thumb.innerHTML = `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div><small>${_esc(f.name.slice(0, 18))}</small>`;
} else {
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
thumb.appendChild(img);
}
pendingBox.appendChild(thumb);
});
});
}
// X-Buttons für vorhandene Media-Items
document.querySelectorAll('#health-media-grid .health-media-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const mediaId = parseInt(btn.dataset.mediaId);
const dogId = _appState.activeDog.id;
if (!mediaId || !entry?.id) return;
try {
await API.health.deleteMedia(dogId, entry.id, mediaId);
// Aus entry.media_items entfernen
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
btn.closest('.health-media-thumb').remove();
// Auch in _data aktualisieren
const list = _data[t] || [];
const idx = list.findIndex(x => x.id === entry.id);
if (idx !== -1) list[idx].media_items = (list[idx].media_items || []).filter(m => m.id !== mediaId);
UI.toast.success('Datei entfernt.');
} catch (err) {
UI.toast.error('Fehler beim Löschen.');
}
});
});
}, 150);
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
@ -1070,16 +1166,26 @@ window.Page_health = (() => {
UI.toast.success('Eintrag erstellt.');
}
// Datei-Upload für Dokumente
if (t === 'dokument' && form.querySelector('[name="datei"]')?.files[0]) {
try {
const formData = new FormData();
formData.append('file', form.querySelector('[name="datei"]').files[0]);
const res = await API.health.uploadDokument(_appState.activeDog.id, saved.id, formData);
saved.datei_url = res.datei_url;
saved.datei_typ = res.datei_typ;
} catch {
UI.toast.warning('Eintrag erstellt, Datei konnte nicht hochgeladen werden.');
// Multi-File-Upload
const fileInput = form.querySelector('[name="datei_neu"]');
const files = fileInput ? Array.from(fileInput.files || []) : [];
if (files.length) {
const dogId = _appState.activeDog.id;
if (!saved.media_items) saved.media_items = [];
for (const f of files) {
try {
const fd = new FormData();
fd.append('file', f);
const res = await API.health.uploadMedia(dogId, saved.id, fd);
saved.media_items.push({ id: res.id, url: res.url, media_type: res.media_type });
// Rückwärtskompatibilität: erste Datei auch als datei_url sichern
if (!saved.datei_url) {
saved.datei_url = res.url;
saved.datei_typ = res.media_type;
}
} catch {
UI.toast.warning(`Datei "${f.name}" konnte nicht hochgeladen werden.`);
}
}
}