Feature: Warteliste pro Wurf — CRUD, Status-Flow, Formular (SW by-v891)

This commit is contained in:
rene 2026-05-13 16:45:46 +02:00
parent e8c2d5b940
commit 67e68bbe2d
8 changed files with 324 additions and 7 deletions

View file

@ -583,10 +583,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=890"></script>
<script src="/js/ui.js?v=890"></script>
<script src="/js/app.js?v=890"></script>
<script src="/js/worlds.js?v=890"></script>
<script src="/js/api.js?v=891"></script>
<script src="/js/ui.js?v=891"></script>
<script src="/js/app.js?v=891"></script>
<script src="/js/worlds.js?v=891"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -709,6 +709,11 @@ const API = (() => {
addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); },
updatePuppy(id, data) { return put(`/litters/puppies/${id}`, data); },
addWeight(id, data) { return post(`/litters/puppies/${id}/weight`, data); },
// Warteliste
waitlist(id) { return get(`/litters/${id}/waitlist`); },
addWaitlist(id, data) { return post(`/litters/${id}/waitlist`, data); },
updateWaitlist(entryId, data) { return put(`/litters/waitlist/${entryId}`, data); },
removeWaitlist(entryId) { return del(`/litters/waitlist/${entryId}`); },
// Öffentlich
public(params) { return get('/litters?' + new URLSearchParams(params || {}).toString()); },
detail(id) { return get(`/litters/${id}`); },

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '890'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '891'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -206,6 +206,14 @@ window.Page_litters = (() => {
});
});
el.querySelectorAll('.litters-waitlist-btn').forEach(btn => {
btn.addEventListener('click', () => _toggleWaitlist(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-add-waitlist-btn').forEach(btn => {
btn.addEventListener('click', () => _showWaitlistForm(parseInt(btn.dataset.id), null));
});
// Aufgeklappten Wurf wiederherstellen
if (_openId) _togglePuppies(_openId, true);
}
@ -248,6 +256,10 @@ window.Page_litters = (() => {
title="Welpen anzeigen">
${UI.icon('caret-down')} Welpen
</button>
<button class="btn btn-ghost btn-sm litters-waitlist-btn" data-id="${l.id}"
title="Warteliste">
${UI.icon('list-bullets')} Warteliste
</button>
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}"
title="Wurf-Fotos verwalten">
${UI.icon('images')} Fotos
@ -281,6 +293,15 @@ window.Page_litters = (() => {
${UI.icon('plus')} Welpen hinzufügen
</button>
</div>
<div class="litters-waitlist-wrap" id="waitlist-wrap-${l.id}" style="display:none">
<div class="litters-waitlist-inner" id="waitlist-inner-${l.id}">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}"
style="margin-top:var(--space-3)">
${UI.icon('plus')} Interessent eintragen
</button>
</div>
</div>`;
}
@ -561,6 +582,180 @@ window.Page_litters = (() => {
}
}
// ----------------------------------------------------------
// Warteliste
// ----------------------------------------------------------
const _WL_STATUS = {
anfrage: { label: 'Anfrage', color: '#6b7280' },
vorgemerkt: { label: 'Vorgemerkt', color: '#f59e0b' },
bestaetigt: { label: 'Bestätigt', color: '#3b82f6' },
abgegeben: { label: 'Abgegeben', color: '#16a34a' },
abgesagt: { label: 'Abgesagt', color: '#dc2626' },
};
function _wlStatusBadge(status) {
const s = _WL_STATUS[status] || _WL_STATUS.anfrage;
return `<span style="background:${s.color}1a;color:${s.color};border:1px solid ${s.color}40;border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${s.label}</span>`;
}
async function _toggleWaitlist(litterId) {
const wrap = document.getElementById(`waitlist-wrap-${litterId}`);
if (!wrap) return;
const isOpen = wrap.style.display !== 'none';
if (isOpen) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
await _loadWaitlist(litterId);
}
async function _loadWaitlist(litterId) {
const inner = document.getElementById(`waitlist-inner-${litterId}`);
if (!inner) return;
try {
const entries = await API.litters.waitlist(litterId);
_renderWaitlist(inner, litterId, entries);
} catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler.')}</p>`;
}
}
function _renderWaitlist(container, litterId, entries) {
if (!entries.length) {
container.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Interessenten eingetragen.</p>`;
return;
}
container.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${entries.map((e, i) => `
<div style="background:var(--c-bg-secondary);border-radius:var(--radius-md);padding:var(--space-3) var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start" data-entry-id="${e.id}">
<div style="background:var(--c-primary);color:white;border-radius:50%;width:1.6rem;height:1.6rem;display:flex;align-items:center;justify-content:center;font-size:var(--text-xs);font-weight:700;flex-shrink:0;margin-top:2px">${i + 1}</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:600;font-size:var(--text-sm)">${_esc(e.name)}</span>
${_wlStatusBadge(e.status)}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''}
${e.wunsch_farbe ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(e.wunsch_farbe)}</span>` : ''}
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${e.email ? `<span>${UI.icon('envelope')} ${_esc(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</span>` : ''}
<span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span>
</div>
${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${_esc(e.nachricht)}"</div>` : ''}
${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${_esc(e.notiz)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs wl-edit-btn" data-entry-id="${e.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
</div>
</div>`).join('')}
</div>`;
container.querySelectorAll('.wl-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const entry = entries.find(e => e.id === parseInt(btn.dataset.entryId));
if (entry) _showWaitlistForm(litterId, entry);
});
});
container.querySelectorAll('.wl-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Interessenten aus der Warteliste entfernen?')) return;
try {
await API.litters.removeWaitlist(parseInt(btn.dataset.entryId));
await _loadWaitlist(litterId);
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}
function _showWaitlistForm(litterId, entry) {
const isEdit = !!entry;
const v = entry || {};
UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
body: `
<form id="wl-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required value="${_esc(v.name || '')}">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email" value="${_esc(v.email || '')}">
</div>
<div class="form-group">
<label class="form-label">Telefon</label>
<input class="form-control" name="telefon" value="${_esc(v.telefon || '')}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht">
<option value="egal" ${(!v.wunsch_geschlecht || v.wunsch_geschlecht === 'egal') ? 'selected' : ''}>Egal</option>
<option value="maennlich" ${v.wunsch_geschlecht === 'maennlich' ? 'selected' : ''}>Rüde </option>
<option value="weiblich" ${v.wunsch_geschlecht === 'weiblich' ? 'selected' : ''}>Hündin </option>
</select>
</div>
<div class="form-group">
<label class="form-label">Wunsch Farbe</label>
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${_esc(v.wunsch_farbe || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Nachricht des Interessenten</label>
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${_esc(v.nachricht || '')}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
${Object.entries(_WL_STATUS).map(([k, s]) => `<option value="${k}" ${(v.status || 'anfrage') === k ? 'selected' : ''}>${s.label}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Position</label>
<input class="form-control" type="number" name="prioritaet" min="0" value="${v.prioritaet ?? 0}">
</div>
</div>
<div class="form-group">
<label class="form-label">Interne Notiz</label>
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${_esc(v.notiz || '')}">
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="wl-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('wl-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = {
name: fd.get('name')?.trim(),
email: fd.get('email')?.trim() || null,
telefon: fd.get('telefon')?.trim() || null,
nachricht: fd.get('nachricht')?.trim() || null,
wunsch_geschlecht: fd.get('wunsch_geschlecht'),
wunsch_farbe: fd.get('wunsch_farbe')?.trim() || null,
prioritaet: parseInt(fd.get('prioritaet')) || 0,
status: fd.get('status'),
notiz: fd.get('notiz')?.trim() || null,
};
try {
if (isEdit) {
await API.litters.updateWaitlist(entry.id, data);
} else {
await API.litters.addWaitlist(litterId, data);
}
UI.modal.close();
await _loadWaitlist(litterId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Interessent eingetragen.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
}
// ----------------------------------------------------------
// Wurf-Formular (neu / bearbeiten)
// ----------------------------------------------------------

View file

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