Feature: Vollständige Züchter-Rolle — Antrag, Würfe, Stammbaum, Genetik
Basis-Features (Schritte 1–11): - Züchter-Antrag mit Dokument-Upload, Admin-Prüfung, E-Mail-Benachrichtigungen - Öffentliches Züchter-Profil + Karten-Marker (lila, certificate-Icon) - Wurfverwaltung: Würfe, Welpen, Gewichtsverlauf, Foto-System - Wurfbörse (öffentlich) mit Filtersuche nach Rasse/Status - Läufigkeits-Tracker: Deckdatum + Wurftermin (+63 Tage, nur für Züchter) - Interessenten-Chat: Kontakt-Button in Wurfbörse und Züchter-Profil - Sidebar-Einträge: Zuchtkartei + Wurfverwaltung für Züchter/Admin Stammbaum & Genetik (Schritte 1–8): - Zuchtkartei: Hunde-Stammdaten mit Vater/Mutter-Verknüpfung - Stammbaum-Visualisierung: 4 Generationen, horizontales CSS-Grid - Gesundheitstests (HD, ED, OCD, Augen…) mit farbigen Ergebnis-Badges - Genetische Tests (MDR1, PRA, DM…): clear/carrier/affected - Titel & Auszeichnungen (CAC, CACIB, IPO…) - Probeverpaarung: IK-Berechnung nach Wright + Ampel-Bewertung - Teilen-Link für öffentliche Hunde-Profile - Kaufvertrag: druckbares HTML-Dokument pro Welpe Technisch: 4 neue Route-Dateien, 5 neue Page-Module, 11 neue DB-Tabellen, icons shield-check + certificate + tree-structure im Sprite — SW by-v465, APP_VER 444
This commit is contained in:
parent
58cb2b4ad3
commit
91340be5a3
24 changed files with 6660 additions and 27 deletions
|
|
@ -6551,6 +6551,132 @@ svg.empty-state-icon {
|
|||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
WURFBÖRSE
|
||||
------------------------------------------------------------ */
|
||||
.wb-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.wb-filter-bar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wb-filter-fields {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wb-filter-rasse { flex: 2; min-width: 160px; }
|
||||
.wb-filter-status { flex: 1; min-width: 130px; }
|
||||
|
||||
.wb-filter-btn { white-space: nowrap; }
|
||||
|
||||
.wb-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.wb-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.wb-cards { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.wb-card {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
|
||||
.wb-card:hover { box-shadow: var(--shadow-md); }
|
||||
|
||||
.wb-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.wb-card-zuechter {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wb-card-rasse {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.wb-card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wb-card-eltern,
|
||||
.wb-card-datum,
|
||||
.wb-card-welpen,
|
||||
.wb-card-preis,
|
||||
.wb-card-gesundheit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.wb-card-beschreibung {
|
||||
margin-top: var(--space-1);
|
||||
line-height: 1.4;
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
.wb-card-footer {
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--c-border-light);
|
||||
}
|
||||
|
||||
/* Status-Badges */
|
||||
.wb-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wb-badge--geplant { background: #6B7280; }
|
||||
.wb-badge--geboren { background: #3B82F6; }
|
||||
.wb-badge--verfuegbar { background: #22C55E; }
|
||||
.wb-badge--abgeschlossen { background: #374151; }
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
OFFLINE-BANNER
|
||||
------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -177,4 +177,13 @@
|
|||
<rect width="256" height="256" fill="none"/>
|
||||
<path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM152,160H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16a8,8,0,0,1,0,16ZM48,80V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80Z"/>
|
||||
</symbol>
|
||||
<symbol id="tree-structure" viewBox="0 0 256 256">
|
||||
<path d="M144,96V80H128a8,8,0,0,0-8,8v80a8,8,0,0,0,8,8h16V160a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16v48a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V192H128a24,24,0,0,1-24-24V136H72v8a16,16,0,0,1-16,16H24A16,16,0,0,1,8,144V112A16,16,0,0,1,24,96H56a16,16,0,0,1,16,16v8h32V88a24,24,0,0,1,24-24h16V48a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16V96a16,16,0,0,1-16,16H160A16,16,0,0,1,144,96Z"/>
|
||||
</symbol>
|
||||
<symbol id="shield-check" viewBox="0 0 256 256">
|
||||
<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm-34.32,69.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"/>
|
||||
</symbol>
|
||||
<symbol id="certificate" viewBox="0 0 256 256">
|
||||
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
|
@ -173,6 +173,9 @@
|
|||
<div class="sidebar-item" data-page="wiki">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="wurfboerse">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Wurfbörse
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="knigge">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg> Knigge
|
||||
</div>
|
||||
|
|
@ -183,6 +186,15 @@
|
|||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item" data-page="zuchthunde" id="sidebar-zuchthunde"
|
||||
style="display:none;color:var(--c-primary,#7c3aed)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree-structure"></use></svg> Zuchtkartei
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="litters" id="sidebar-litters"
|
||||
style="display:none;color:var(--c-primary,#7c3aed)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg> Wurfverwaltung
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item" data-page="social" id="sidebar-social"
|
||||
style="display:none;color:var(--c-warning,#f59e0b)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Social Media
|
||||
|
|
@ -338,6 +350,24 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-zuchthunde">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
<section class="page" id="page-zucht-profil">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
<section class="page" id="page-litters">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-wurfboerse">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-breeder">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-social">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -606,12 +606,90 @@ const API = (() => {
|
|||
return new Date().toLocaleString('sv').replace(' ', 'T');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER
|
||||
// ----------------------------------------------------------
|
||||
const breeder = {
|
||||
status() { return get('/breeder/status'); },
|
||||
apply(form) { return upload('/breeder/apply', form); },
|
||||
profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); },
|
||||
mapMarkers() { return get('/breeder/map'); },
|
||||
updateProfile(data) { return put('/breeder/profile', data); },
|
||||
pendingList() { return get('/admin/breeders/pending'); },
|
||||
documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
|
||||
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
|
||||
approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); },
|
||||
reject(userId, grund) { return post(`/admin/breeder/${userId}/reject`, { grund }); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// WÜRFE (Züchter-Wurf-Verwaltung)
|
||||
// ----------------------------------------------------------
|
||||
const litters = {
|
||||
// Züchter: eigene Würfe
|
||||
myList() { return get('/litters/my'); },
|
||||
create(data) { return post('/litters', data); },
|
||||
update(id, data) { return put(`/litters/${id}`, data); },
|
||||
remove(id) { return del(`/litters/${id}`); },
|
||||
// Welpen
|
||||
puppies(id) { return get(`/litters/${id}/puppies`); },
|
||||
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); },
|
||||
// Öffentlich
|
||||
public(params) { return get('/litters?' + new URLSearchParams(params || {}).toString()); },
|
||||
detail(id) { return get(`/litters/${id}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER-FOTOS
|
||||
// ----------------------------------------------------------
|
||||
const breederPhotos = {
|
||||
upload(form) { return upload('/breeder/photos/upload', form); },
|
||||
list(entityType, entityId) { return get(`/photos/${entityType}/${entityId}`); },
|
||||
updateVisibility(id, visibility) { return patch(`/breeder/photos/${id}/visibility`, { visibility }); },
|
||||
setPrimary(id) { return patch(`/breeder/photos/${id}/primary`, {}); },
|
||||
updateCaption(id, caption) { return patch(`/breeder/photos/${id}/caption`, { caption }); },
|
||||
remove(id) { return del(`/breeder/photos/${id}`); },
|
||||
};
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||
// ----------------------------------------------------------
|
||||
// ZUCHTKARTEI (Hunde-Stammdaten, Gesundheit, Genetik, Titel)
|
||||
// ----------------------------------------------------------
|
||||
const zuchthunde = {
|
||||
// Hunde
|
||||
list() { return get('/zuchthunde'); },
|
||||
get(id) { return get(`/zuchthunde/${id}`); },
|
||||
create(data) { return post('/zuchthunde', data); },
|
||||
update(id, data) { return put(`/zuchthunde/${id}`, data); },
|
||||
remove(id) { return del(`/zuchthunde/${id}`); },
|
||||
pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); },
|
||||
// Gesundheitstests
|
||||
healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); },
|
||||
addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); },
|
||||
updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); },
|
||||
deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); },
|
||||
// Gentests
|
||||
geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); },
|
||||
addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); },
|
||||
updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); },
|
||||
deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); },
|
||||
// Titel
|
||||
titles(id) { return get(`/zuchthunde/${id}/titles`); },
|
||||
addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); },
|
||||
updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); },
|
||||
deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); },
|
||||
// Probeverpaarung
|
||||
trialMating(vaterId, mutterId) { return post('/zuchthunde/trial-mating', { vater_id: vaterId, mutter_id: mutterId }); },
|
||||
};
|
||||
|
||||
breeder, litters, breederPhotos, zuchthunde,
|
||||
subscribeToPush, getLocation, clientNow,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '429'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '444'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
|
|
@ -62,6 +62,11 @@ const App = (() => {
|
|||
datenschutz: { title: 'Datenschutz', module: null },
|
||||
widget: { title: 'Widget', module: null, requiresAuth: true },
|
||||
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
|
||||
breeder: { title: 'Züchter-Profil', module: null },
|
||||
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
|
||||
wurfboerse: { title: 'Wurfbörse', module: null },
|
||||
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -430,6 +435,11 @@ const App = (() => {
|
|||
|| state.user.is_moderator;
|
||||
moderationItem.style.display = isMod ? '' : 'none';
|
||||
}
|
||||
const isBreeder = state.user.rolle === 'breeder' || state.user.rolle === 'admin';
|
||||
const littersItem = document.getElementById('sidebar-litters');
|
||||
if (littersItem) littersItem.style.display = isBreeder ? '' : 'none';
|
||||
const zuchthundeItem = document.getElementById('sidebar-zuchthunde');
|
||||
if (zuchthundeItem) zuchthundeItem.style.display = isBreeder ? '' : 'none';
|
||||
const socialItem = document.getElementById('sidebar-social');
|
||||
if (socialItem) {
|
||||
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ window.Page_admin = (() => {
|
|||
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
||||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||||
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
||||
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
|
||||
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
|
||||
{ id: 'social', label: 'Social Media', icon: 'camera' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||
|
|
@ -81,6 +82,7 @@ window.Page_admin = (() => {
|
|||
case 'uebersicht': await _renderStats(el); break;
|
||||
case 'nutzer': await _renderUsers(el); break;
|
||||
case 'moderation': await _renderModeration(el); break;
|
||||
case 'zuchter': await _renderZuechter(el); break;
|
||||
case 'forum': await _renderForum(el); break;
|
||||
case 'social': await _renderSocial(el); break;
|
||||
case 'analytics': await _renderAnalytics(el); break;
|
||||
|
|
@ -309,7 +311,39 @@ window.Page_admin = (() => {
|
|||
// TAB: ÜBERSICHT
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderStats(el) {
|
||||
const s = await API.get('/admin/stats');
|
||||
const [s, ki] = await Promise.all([
|
||||
API.get('/admin/stats'),
|
||||
API.get('/admin/ki/status').catch(() => null),
|
||||
]);
|
||||
|
||||
const _kiStatusBadge = () => {
|
||||
if (!ki) return '';
|
||||
if (ki.mode === 'off') {
|
||||
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md)">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--c-text-muted);flex-shrink:0"></span>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">KI-Modus: <strong>off</strong></span>
|
||||
</div>`;
|
||||
}
|
||||
const dot = ki.local_reachable ? 'var(--c-success)' : 'var(--c-danger)';
|
||||
const label = ki.local_reachable ? 'Lokal erreichbar' : 'Nicht erreichbar';
|
||||
const model = ki.local_model_loaded || ki.local_model_config || '?';
|
||||
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md);flex-wrap:wrap">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0;
|
||||
box-shadow:0 0 4px ${dot}"></span>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:600">${label}</span>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">·</span>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-family:monospace">${_esc(model)}</span>
|
||||
<span style="margin-left:auto;font-size:10px;padding:1px 6px;border-radius:10px;
|
||||
background:var(--c-surface);color:var(--c-text-muted);border:1px solid var(--c-border)">
|
||||
Modus: ${ki.local_reachable ? 'local' : 'cloud'}
|
||||
</span>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="adm-stats-grid">
|
||||
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
|
||||
|
|
@ -331,6 +365,7 @@ window.Page_admin = (() => {
|
|||
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Nutzung</p>
|
||||
${_kiStatusBadge()}
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${[
|
||||
['☁️ Claude (7 Tage)', s.ki_cloud_week, 'var(--c-primary)'],
|
||||
|
|
@ -1212,6 +1247,195 @@ window.Page_admin = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: ZÜCHTER-ANTRÄGE
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderZuechter(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
<button class="btn btn-ghost btn-sm" id="adm-zuchter-refresh">
|
||||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-zuchter-list">Lade…</div>
|
||||
`;
|
||||
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () =>
|
||||
_loadZuechterAntraege(el.querySelector('#adm-zuchter-list'))
|
||||
);
|
||||
await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list'));
|
||||
}
|
||||
|
||||
async function _loadZuechterAntraege(el) {
|
||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||
let antraege;
|
||||
try {
|
||||
antraege = await API.breeder.pendingList();
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Anträge konnten nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!antraege.length) {
|
||||
el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.');
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
${antraege.map(a => `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
|
||||
|
||||
<!-- Infos -->
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);
|
||||
color:var(--c-text);margin-bottom:var(--space-1)">
|
||||
${_esc(a.name)}
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400;margin-left:6px">
|
||||
${_esc(a.email)}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-3);font-size:var(--text-xs);
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
<span>${UI.icon('paw-print')} ${_esc(a.rasse_text || '–')}</span>
|
||||
<span>${UI.icon('house-line')} ${_esc(a.zwingername || '–')}</span>
|
||||
<span>${UI.icon('users')} ${_esc(a.verein || '–')}</span>
|
||||
<span>${UI.icon('map-pin')} ${_esc(a.stadt || '–')}</span>
|
||||
<span style="color:${a.vdh_mitglied ? 'var(--c-success)' : 'var(--c-text-muted)'}">
|
||||
${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'}
|
||||
</span>
|
||||
${a.created_at ? `<span style="color:var(--c-text-muted)">${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')}</span>` : ''}
|
||||
</div>
|
||||
${a.beschreibung ? `
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-sm);margin-top:var(--space-1)">
|
||||
${_esc(a.beschreibung)}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Aktionen -->
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
|
||||
<button class="btn btn-sm btn-secondary adm-breeder-docs"
|
||||
data-uid="${a.user_id || a.id}">
|
||||
${UI.icon('file-text')} Dokumente
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary adm-breeder-approve"
|
||||
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}">
|
||||
${UI.icon('check')} Freischalten
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost adm-breeder-reject"
|
||||
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}"
|
||||
style="color:var(--c-danger)">
|
||||
${UI.icon('x')} Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Dokumente anzeigen
|
||||
el.querySelectorAll('.adm-breeder-docs').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const uid = btn.dataset.uid;
|
||||
let docs;
|
||||
try {
|
||||
docs = await API.breeder.documents(uid);
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Dokumente konnten nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('file-text')} Hochgeladene Dokumente`,
|
||||
body: docs.length
|
||||
? `<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
${docs.map(d => `
|
||||
<a href="${_esc(API.breeder.documentUrl(uid, d.id))}"
|
||||
target="_blank" rel="noopener"
|
||||
class="btn btn-secondary"
|
||||
style="text-align:left;word-break:break-all">
|
||||
${UI.icon('file')} ${_esc(d.filename || d.name || 'Dokument ' + d.id)}
|
||||
</a>`).join('')}
|
||||
</div>`
|
||||
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine Dokumente hochgeladen.</p>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Freischalten
|
||||
el.querySelectorAll('.adm-breeder-approve').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const ok = window.confirm(`${btn.dataset.name} als Züchter freischalten?`);
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await API.breeder.approve(btn.dataset.uid);
|
||||
UI.toast.success(res.message || `${btn.dataset.name} freigeschaltet.`);
|
||||
await _loadZuechterAntraege(el);
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Freischaltung fehlgeschlagen.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ablehnen
|
||||
el.querySelectorAll('.adm-breeder-reject').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const uid = btn.dataset.uid;
|
||||
const name = btn.dataset.name;
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('x-circle')} Antrag ablehnen: ${name}`,
|
||||
body: `
|
||||
<form id="breeder-reject-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
||||
Bitte gib einen Ablehnungsgrund an. Dieser wird dem Antragsteller mitgeteilt.
|
||||
</p>
|
||||
<textarea id="breeder-reject-grund" name="grund" rows="4" required
|
||||
placeholder="z. B. Dokumente unvollständig, Rasse nicht unterstützt…"
|
||||
style="width:100%;box-sizing:border-box;padding:var(--space-2) 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-surface);color:var(--c-text);resize:vertical"></textarea>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button class="btn btn-primary" id="breeder-reject-submit"
|
||||
form="breeder-reject-form" style="width:100%;background:var(--c-danger);border-color:var(--c-danger)">
|
||||
Antrag ablehnen
|
||||
</button>
|
||||
<button class="btn btn-ghost" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('breeder-reject-form')?.addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
const grund = document.getElementById('breeder-reject-grund')?.value?.trim();
|
||||
if (!grund) {
|
||||
UI.toast.warning('Bitte einen Ablehnungsgrund angeben.');
|
||||
return;
|
||||
}
|
||||
const submitBtn = document.getElementById('breeder-reject-submit');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
try {
|
||||
const res = await API.breeder.reject(uid, grund);
|
||||
UI.modal.close?.();
|
||||
UI.toast.success(res.message || `Antrag von ${name} abgelehnt.`);
|
||||
await _loadZuechterAntraege(el);
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Ablehnung fehlgeschlagen.');
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderJobs(el) {
|
||||
el.innerHTML = `
|
||||
|
|
|
|||
210
backend/static/js/pages/breeder.js
Normal file
210
backend/static/js/pages/breeder.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Öffentliches Züchter-Profil
|
||||
Seiten-Modul: Zeigt das verifizierte Profil eines Züchters.
|
||||
============================================================ */
|
||||
|
||||
window.Page_breeder = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState, params = {}) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
|
||||
// Zwingername aus params oder URL-Pfad (/breeder/vom-sonnenfeld)
|
||||
const zwingername = params?.zwingername
|
||||
|| decodeURIComponent((window.location.pathname.split('/breeder/')[1] || '').replace(/\/$/, ''));
|
||||
|
||||
if (!zwingername) {
|
||||
container.innerHTML = '<div style="padding:var(--space-6)">Kein Zwingername angegeben.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<div style="padding:var(--space-6);text-align:center">Lade…</div>';
|
||||
|
||||
try {
|
||||
const p = await API.breeder.profile(zwingername);
|
||||
_render(p);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="padding:var(--space-6)">${_esc(e.message || 'Züchter nicht gefunden.')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER
|
||||
// ----------------------------------------------------------
|
||||
function _render(p) {
|
||||
const verifiedDate = p.verified_at
|
||||
? new Date(p.verified_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
const websiteHtml = p.website
|
||||
? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
|
||||
style="color:var(--c-primary);text-decoration:none;word-break:break-all">
|
||||
${UI.icon('arrow-square-out')} ${_esc(p.website)}
|
||||
</a>`
|
||||
: '';
|
||||
|
||||
const beschreibungHtml = p.beschreibung
|
||||
? `<div class="card" style="margin-bottom:var(--space-3)">
|
||||
<p style="margin:0;white-space:pre-line;color:var(--c-text-secondary)">${_esc(p.beschreibung)}</p>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
_container.innerHTML = `
|
||||
<div style="padding:var(--space-4)">
|
||||
|
||||
<!-- Header-Card -->
|
||||
<div class="card" style="margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
|
||||
<div style="flex:1;min-width:0">
|
||||
<h2 style="margin:0 0 var(--space-1);font-size:var(--text-xl);word-break:break-word">
|
||||
${UI.icon('certificate')} ${_esc(p.zwingername)}
|
||||
</h2>
|
||||
<span class="badge badge-primary" style="background:var(--c-success,#22C55E);color:#fff;font-size:var(--text-xs)">
|
||||
${UI.icon('seal-check')} Verifizierter Züchter
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details-Card -->
|
||||
<div class="card" style="margin-bottom:var(--space-3)">
|
||||
<dl style="margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Rasse</dt>
|
||||
<dd style="margin:0;font-weight:600">${_esc(p.rasse_text || '–')}</dd>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verein</dt>
|
||||
<dd style="margin:0">${_esc(p.verein || '–')}</dd>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">VDH-Mitglied</dt>
|
||||
<dd style="margin:0">
|
||||
${p.vdh_mitglied
|
||||
? `<span class="badge badge-primary">${UI.icon('check')} Ja</span>`
|
||||
: `<span style="color:var(--c-text-secondary)">Nein</span>`}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Stadt</dt>
|
||||
<dd style="margin:0">${_esc(p.stadt || '–')}</dd>
|
||||
</div>
|
||||
|
||||
${p.website ? `
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Website</dt>
|
||||
<dd style="margin:0">${websiteHtml}</dd>
|
||||
</div>` : ''}
|
||||
|
||||
${verifiedDate ? `
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verifiziert</dt>
|
||||
<dd style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">${verifiedDate}</dd>
|
||||
</div>` : ''}
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);align-items:baseline">
|
||||
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Züchter</dt>
|
||||
<dd style="margin:0">${_esc(p.zuechter_name || '–')}</dd>
|
||||
</div>
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung -->
|
||||
${beschreibungHtml}
|
||||
|
||||
<!-- Fotos (werden asynchron nachgeladen) -->
|
||||
<div id="breeder-photos-section"></div>
|
||||
|
||||
<!-- Kontakt-Button -->
|
||||
${(() => {
|
||||
if (!p.zuechter_user_id) return '';
|
||||
const isLoggedIn = !!_appState?.user;
|
||||
const isOwnProfile = _appState?.user?.id === p.zuechter_user_id;
|
||||
if (isOwnProfile) return '';
|
||||
if (isLoggedIn) {
|
||||
return `<button class="btn btn-primary breeder-chat-btn" style="width:100%">
|
||||
${UI.icon('chat-circle')} Nachricht senden
|
||||
</button>`;
|
||||
}
|
||||
return `<button class="btn btn-primary breeder-login-btn" style="width:100%">
|
||||
${UI.icon('sign-in')} Anmelden um zu schreiben
|
||||
</button>`;
|
||||
})()}
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
_container.querySelector('.breeder-chat-btn')?.addEventListener('click', () => {
|
||||
_contactBreeder(p.zuechter_user_id);
|
||||
});
|
||||
_container.querySelector('.breeder-login-btn')?.addEventListener('click', () => {
|
||||
App.navigate('settings');
|
||||
});
|
||||
|
||||
// Öffentliche Fotos nachladen
|
||||
_loadBreederPhotos(p.id);
|
||||
}
|
||||
|
||||
async function _loadBreederPhotos(breederId) {
|
||||
const section = document.getElementById('breeder-photos-section');
|
||||
if (!section) return;
|
||||
try {
|
||||
const photos = await API.breederPhotos.list('breeder', breederId);
|
||||
if (!photos || !photos.length) return;
|
||||
|
||||
section.innerHTML = `
|
||||
<div class="card" style="margin-bottom:var(--space-3)">
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-md);font-weight:var(--weight-semibold)">
|
||||
${UI.icon('images')} Fotos
|
||||
</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:var(--space-2)">
|
||||
${photos.map(ph => {
|
||||
const thumb = ph.thumbnail_url || ph.url || '';
|
||||
return `
|
||||
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer"
|
||||
style="display:block;border-radius:var(--radius-md);overflow:hidden;
|
||||
border:1px solid var(--c-border);aspect-ratio:1">
|
||||
<img src="${_esc(thumb)}"
|
||||
alt="${_esc(ph.caption || '')}"
|
||||
loading="lazy"
|
||||
style="width:100%;height:100%;object-fit:cover;display:block"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</a>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (_) {
|
||||
// Fotos sind nicht kritisch — bei Fehler still ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
async function _contactBreeder(breederId) {
|
||||
if (!_appState?.user) {
|
||||
App.navigate('settings');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await API.chat.start(breederId);
|
||||
App.navigate('chat');
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.');
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {}
|
||||
function onDogChange() {}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -1011,6 +1011,14 @@ window.Page_health = (() => {
|
|||
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
|
||||
if (e.typ === 'laeufigkeit' && e.wert) rows.push(['Dauer', `${e.wert} Tage`]);
|
||||
if (e.naechstes) rows.push([e.typ === 'laeufigkeit' ? 'Nächste erwartet' : 'Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
|
||||
if (e.typ === 'laeufigkeit' && e.deckdatum) rows.push(['Deckdatum', UI.time.format(e.deckdatum + 'T00:00:00')]);
|
||||
if (e.typ === 'laeufigkeit' && e.wurftermin) {
|
||||
const wurfDate = new Date(e.wurftermin + 'T00:00:00');
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const diffDays = Math.round((wurfDate - today) / 86400000);
|
||||
const zukunft = diffDays > 0 ? ` <span style="color:var(--c-primary);font-weight:600">in ${diffDays} Tagen</span>` : '';
|
||||
rows.push(['Wurftermin', UI.time.format(e.wurftermin + 'T00:00:00') + zukunft]);
|
||||
}
|
||||
if (e.tierarzt_id) {
|
||||
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
|
||||
if (praxis) {
|
||||
|
|
@ -1478,6 +1486,27 @@ window.Page_health = (() => {
|
|||
<input class="form-control" type="date" name="naechstes"
|
||||
value="${entry?.naechstes || ''}" id="laeufi-naechstes">
|
||||
</div>
|
||||
${['breeder', 'admin'].includes(_appState.user?.rolle) ? `
|
||||
<div class="form-group" id="laeufi-zuechter-fields" style="margin-top:var(--space-4);
|
||||
padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
|
||||
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--space-3)">
|
||||
Zucht (optional)
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Deckdatum</label>
|
||||
<input class="form-control" type="date" name="deckdatum"
|
||||
value="${entry?.deckdatum || ''}" id="laeufi-deckdatum">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Wurftermin (63 Tage nach Deckung)</label>
|
||||
<input class="form-control" type="date" name="wurftermin"
|
||||
value="${entry?.wurftermin || ''}" id="laeufi-wurftermin" readonly
|
||||
style="background:var(--c-surface-2)">
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<script>
|
||||
(function() {
|
||||
const datum = document.querySelector('[name="datum"]');
|
||||
|
|
@ -1495,6 +1524,16 @@ window.Page_health = (() => {
|
|||
datum?.addEventListener('change', updateNext);
|
||||
interval?.addEventListener('change', updateNext);
|
||||
if (!naechstes?.value) updateNext();
|
||||
|
||||
const deckdatum = document.getElementById('laeufi-deckdatum');
|
||||
const wurftermin = document.getElementById('laeufi-wurftermin');
|
||||
deckdatum?.addEventListener('change', e => {
|
||||
const deckDate = new Date(e.target.value);
|
||||
if (!isNaN(deckDate)) {
|
||||
deckDate.setDate(deckDate.getDate() + 63);
|
||||
wurftermin.value = deckDate.toISOString().split('T')[0];
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
|
@ -1524,6 +1563,8 @@ window.Page_health = (() => {
|
|||
}
|
||||
if (typ === 'laeufigkeit') {
|
||||
p.bezeichnung = p.bezeichnung || 'Läufigkeit';
|
||||
p.deckdatum = fd.deckdatum || null;
|
||||
p.wurftermin = fd.wurftermin || null;
|
||||
}
|
||||
if (fd.kosten) p.kosten = parseFloat(fd.kosten.toString().replace(',', '.'));
|
||||
if (fd.tierarzt_id) {
|
||||
|
|
|
|||
972
backend/static/js/pages/litters.js
Normal file
972
backend/static/js/pages/litters.js
Normal file
|
|
@ -0,0 +1,972 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Wurfverwaltung
|
||||
Züchter verwalten ihre Würfe und Welpen
|
||||
============================================================ */
|
||||
|
||||
window.Page_litters = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _litters = []; // geladene Würfe
|
||||
let _openId = null; // aufgeklappter Wurf
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ----------------------------------------------------------
|
||||
function _emptyState(icon, title, text) {
|
||||
return `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon(icon)}</div>
|
||||
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
|
||||
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function _statusBadge(status) {
|
||||
const map = {
|
||||
geplant: { label: 'Geplant', color: '#6B7280' },
|
||||
geboren: { label: 'Geboren', color: '#3B82F6' },
|
||||
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
|
||||
abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
|
||||
};
|
||||
const s = map[status] || { label: status, color: '#6B7280' };
|
||||
return `<span class="litters-badge" style="background:${s.color}">${_esc(s.label)}</span>`;
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso + 'T12:00:00');
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function _genderIcon(g) {
|
||||
if (g === 'maennlich') return UI.icon('gender-male');
|
||||
if (g === 'weiblich') return UI.icon('gender-female');
|
||||
return '';
|
||||
}
|
||||
|
||||
function _puppyStatusBadge(status) {
|
||||
const map = {
|
||||
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
|
||||
reserviert: { label: 'Reserviert', color: '#F59E0B' },
|
||||
abgegeben: { label: 'Abgegeben', color: '#6B7280' },
|
||||
};
|
||||
const s = map[status] || { label: status, color: '#9CA3AF' };
|
||||
return `<span class="litters-badge litters-badge--sm" style="background:${s.color}">${_esc(s.label)}</span>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
|
||||
// Auth-Guard
|
||||
const u = _appState.user;
|
||||
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) {
|
||||
_container.innerHTML = _emptyState('lock', 'Kein Zugriff', 'Diese Seite ist nur für verifizierte Züchter.');
|
||||
return;
|
||||
}
|
||||
|
||||
_render();
|
||||
await _loadLitters();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const u = _appState?.user;
|
||||
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) return;
|
||||
_loadLitters();
|
||||
}
|
||||
|
||||
function onDogChange() {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Grundstruktur rendern
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="litters-layout">
|
||||
<div class="by-toolbar">
|
||||
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
|
||||
${UI.icon('dog')} Meine Würfe
|
||||
</h2>
|
||||
<button class="btn btn-primary btn-sm" id="litters-new-btn">
|
||||
${UI.icon('plus')} Neuer Wurf
|
||||
</button>
|
||||
</div>
|
||||
<div id="litters-list">
|
||||
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('litters-new-btn')?.addEventListener('click', () => {
|
||||
_showLitterForm(null);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Würfe laden
|
||||
// ----------------------------------------------------------
|
||||
async function _loadLitters() {
|
||||
try {
|
||||
_litters = await API.litters.myList();
|
||||
_renderList();
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
const el = document.getElementById('litters-list');
|
||||
if (el) el.innerHTML = _emptyState('certificate', 'Kein Züchter-Profil',
|
||||
'Stelle zuerst einen Züchter-Antrag in den Einstellungen, um Würfe verwalten zu können.');
|
||||
} else {
|
||||
UI.toast.error(err.message || 'Fehler beim Laden der Würfe.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Würfe-Liste rendern
|
||||
// ----------------------------------------------------------
|
||||
function _renderList() {
|
||||
const el = document.getElementById('litters-list');
|
||||
if (!el) return;
|
||||
|
||||
if (!_litters.length) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
|
||||
<p style="color:var(--c-text-secondary)">Noch keine Würfe angelegt.</p>
|
||||
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn">
|
||||
${UI.icon('plus')} Ersten Wurf anlegen
|
||||
</button>
|
||||
</div>`;
|
||||
document.getElementById('litters-first-btn')?.addEventListener('click', () => _showLitterForm(null));
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = _litters.map(l => _litterCardHTML(l)).join('');
|
||||
|
||||
// Events
|
||||
el.querySelectorAll('.litters-card-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
_togglePuppies(id);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-photos-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
const litter = _litters.find(l => l.id === id);
|
||||
if (litter) _showPhotosModal('litter', litter.id, litter.zwingername || `Wurf #${litter.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-parent-photos-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
const litter = _litters.find(l => l.id === id);
|
||||
if (!litter) return;
|
||||
const label = [litter.vater_name, litter.mutter_name].filter(Boolean).join(' × ') || `Eltern Wurf #${id}`;
|
||||
_showPhotosModal('parent', litter.id, label);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
const litter = _litters.find(l => l.id === id);
|
||||
if (litter) _showLitterForm(litter);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
_deleteLitter(id);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
_showPuppyForm(id, null);
|
||||
});
|
||||
});
|
||||
|
||||
// Aufgeklappten Wurf wiederherstellen
|
||||
if (_openId) _togglePuppies(_openId, true);
|
||||
}
|
||||
|
||||
function _litterCardHTML(l) {
|
||||
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
|
||||
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
|
||||
const datumLabel = l.geburt_datum
|
||||
? `Geburt: ${_fmtDate(l.geburt_datum)}`
|
||||
: l.erwartetes_datum
|
||||
? `Erwartet: ${_fmtDate(l.erwartetes_datum)}`
|
||||
: '—';
|
||||
|
||||
const elternLabel = [l.vater_name, l.mutter_name]
|
||||
.filter(Boolean)
|
||||
.map(n => _esc(n))
|
||||
.join(' × ') || '—';
|
||||
|
||||
const sichtbarLabel = l.sichtbar
|
||||
? `<span style="color:var(--c-success);font-size:var(--text-xs)">${UI.icon('eye')} Öffentlich</span>`
|
||||
: `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.icon('eye-slash')} Nicht öffentlich</span>`;
|
||||
|
||||
return `
|
||||
<div class="litters-card" id="litter-card-${l.id}">
|
||||
<div class="litters-card-header">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="litters-card-title">
|
||||
${elternLabel}
|
||||
${_statusBadge(l.status)}
|
||||
</div>
|
||||
<div class="litters-card-meta">
|
||||
${UI.icon('calendar-dots')} ${_esc(datumLabel)} ·
|
||||
${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar
|
||||
· ${sichtbarLabel}
|
||||
</div>
|
||||
${l.preis_spanne ? `<div class="litters-card-meta">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</div>` : ''}
|
||||
</div>
|
||||
<div class="litters-card-actions">
|
||||
<button class="btn btn-ghost btn-sm litters-card-toggle" data-id="${l.id}"
|
||||
title="Welpen anzeigen">
|
||||
${UI.icon('caret-down')} Welpen
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}"
|
||||
title="Wurf-Fotos verwalten">
|
||||
${UI.icon('images')} Fotos
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm litters-parent-photos-btn" data-id="${l.id}"
|
||||
title="Elterntier-Fotos verwalten">
|
||||
${UI.icon('users')} Eltern
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}"
|
||||
title="Bearbeiten">
|
||||
${UI.icon('pencil-simple')}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}"
|
||||
title="Löschen" style="color:var(--c-danger)">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${l.beschreibung ? `<div class="litters-card-desc">${_esc(l.beschreibung)}</div>` : ''}
|
||||
<div class="litters-puppies-wrap" id="puppies-wrap-${l.id}" style="display:none">
|
||||
<div class="litters-puppies-inner" id="puppies-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-puppy-btn" data-id="${l.id}"
|
||||
style="margin-top:var(--space-3)">
|
||||
${UI.icon('plus')} Welpen hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Welpen aufklappen / zuklappen
|
||||
// ----------------------------------------------------------
|
||||
async function _togglePuppies(litterId, forceOpen = false) {
|
||||
const wrap = document.getElementById(`puppies-wrap-${litterId}`);
|
||||
if (!wrap) return;
|
||||
|
||||
const isOpen = wrap.style.display !== 'none';
|
||||
|
||||
if (isOpen && !forceOpen) {
|
||||
wrap.style.display = 'none';
|
||||
_openId = null;
|
||||
// Caret zurücksetzen
|
||||
const btn = document.querySelector(`.litters-card-toggle[data-id="${litterId}"]`);
|
||||
if (btn) btn.innerHTML = `${UI.icon('caret-down')} Welpen`;
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.style.display = '';
|
||||
_openId = litterId;
|
||||
const btn = document.querySelector(`.litters-card-toggle[data-id="${litterId}"]`);
|
||||
if (btn) btn.innerHTML = `${UI.icon('caret-up')} Welpen`;
|
||||
|
||||
await _loadPuppies(litterId);
|
||||
}
|
||||
|
||||
async function _loadPuppies(litterId) {
|
||||
const inner = document.getElementById(`puppies-inner-${litterId}`);
|
||||
if (!inner) return;
|
||||
try {
|
||||
const puppies = await API.litters.puppies(litterId);
|
||||
_renderPuppies(inner, litterId, puppies);
|
||||
} catch (err) {
|
||||
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderPuppies(container, litterId, puppies) {
|
||||
if (!puppies.length) {
|
||||
container.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Welpen eingetragen.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = puppies.map(p => `
|
||||
<div class="litters-puppy-row" data-puppy-id="${p.id}">
|
||||
<div class="litters-puppy-info">
|
||||
${_genderIcon(p.geschlecht)}
|
||||
<span class="litters-puppy-name">${p.name ? _esc(p.name) : '<em style="color:var(--c-text-muted)">Unbenannt</em>'}</span>
|
||||
${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(p.farbe)}</span>` : ''}
|
||||
${_puppyStatusBadge(p.status)}
|
||||
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" style="font-size:var(--text-xs);color:var(--c-text-secondary)"></span>
|
||||
</div>
|
||||
<div class="litters-puppy-actions">
|
||||
<button class="btn btn-ghost btn-xs litters-puppy-photo-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
|
||||
title="Fotos">${UI.icon('image')}</button>
|
||||
<button class="btn btn-ghost btn-xs litters-puppy-weight-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
|
||||
title="Gewichtsverlauf">${UI.icon('scales')}</button>
|
||||
<button class="btn btn-ghost btn-xs litters-puppy-contract-btn" data-puppy-id="${p.id}"
|
||||
title="Kaufvertrag">${UI.icon('file-text')}</button>
|
||||
<button class="btn btn-ghost btn-xs litters-puppy-edit-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
|
||||
title="Welpe bearbeiten">${UI.icon('pencil-simple')}</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
container.querySelectorAll('.litters-puppy-photo-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const pid = parseInt(btn.dataset.puppyId);
|
||||
const puppy = puppies.find(p => p.id === pid);
|
||||
if (puppy) _showPhotosModal('puppy', puppy.id, puppy.name || 'Welpe');
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.litters-puppy-edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const pid = parseInt(btn.dataset.puppyId);
|
||||
const lid = parseInt(btn.dataset.litterId);
|
||||
const puppy = puppies.find(p => p.id === pid);
|
||||
if (puppy) _showPuppyForm(lid, puppy);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.litters-puppy-weight-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const pid = parseInt(btn.dataset.puppyId);
|
||||
const puppy = puppies.find(p => p.id === pid);
|
||||
if (puppy) _showWeightModal(puppy);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.litters-puppy-contract-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const pid = parseInt(btn.dataset.puppyId);
|
||||
const puppy = puppies.find(p => p.id === pid);
|
||||
if (puppy) _showContractModal(puppy);
|
||||
});
|
||||
});
|
||||
|
||||
// Letztes Gewicht für jeden Welpen laden
|
||||
puppies.forEach(p => _loadLastWeight(p.id));
|
||||
}
|
||||
|
||||
async function _loadLastWeight(puppyId) {
|
||||
try {
|
||||
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
|
||||
const el = document.getElementById(`puppy-last-weight-${puppyId}`);
|
||||
if (!el) return;
|
||||
if (weights && weights.length) {
|
||||
const w = weights[0];
|
||||
el.innerHTML = `${UI.icon('scales')} ${w.gewicht_g} g (${_fmtDate(w.gemessen_am)})`;
|
||||
}
|
||||
} catch (_) {
|
||||
// Gewichte nicht kritisch — still ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
function _showWeightModal(puppy) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const puppyLabel = puppy.name || 'Welpe';
|
||||
|
||||
const body = `
|
||||
<div id="weight-history" style="margin-bottom:var(--space-3)">
|
||||
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</p>
|
||||
</div>
|
||||
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
|
||||
<form id="weight-form" style="display:flex;gap:var(--space-2);align-items:flex-end">
|
||||
<div style="flex:1">
|
||||
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Gewicht (g)</label>
|
||||
<input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Datum</label>
|
||||
<input class="form-control" name="gemessen_am" type="date" required value="${today}">
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="submit" form="weight-form" class="btn btn-primary" id="weight-submit">
|
||||
${UI.icon('floppy-disk')} Speichern
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
||||
// Gewichte laden und rendern
|
||||
_loadWeightHistory(puppy.id);
|
||||
|
||||
document.getElementById('weight-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('weight-submit');
|
||||
const fd = new FormData(e.target);
|
||||
const payload = {
|
||||
gewicht_g: parseFloat(fd.get('gewicht_g')),
|
||||
gemessen_am: fd.get('gemessen_am'),
|
||||
};
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.litters.addWeight(puppy.id, payload);
|
||||
UI.toast.success('Gewicht gespeichert.');
|
||||
e.target.reset();
|
||||
document.querySelector('[name="gemessen_am"]').value = today;
|
||||
_loadWeightHistory(puppy.id);
|
||||
// Letztes Gewicht im Welpen-Eintrag aktualisieren
|
||||
_loadLastWeight(puppy.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadWeightHistory(puppyId) {
|
||||
const el = document.getElementById('weight-history');
|
||||
if (!el) return;
|
||||
try {
|
||||
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
|
||||
if (!weights || !weights.length) {
|
||||
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Messungen eingetragen.</p>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<table style="width:100%;font-size:var(--text-sm);border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="color:var(--c-text-secondary);font-size:var(--text-xs)">
|
||||
<th style="text-align:left;padding:var(--space-1) var(--space-2)">Datum</th>
|
||||
<th style="text-align:right;padding:var(--space-1) var(--space-2)">Gewicht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${weights.map((w, i) => `
|
||||
<tr style="border-top:1px solid var(--c-border)${i === 0 ? ';font-weight:var(--weight-semibold)' : ''}">
|
||||
<td style="padding:var(--space-1) var(--space-2)">${_fmtDate(w.gemessen_am)}</td>
|
||||
<td style="padding:var(--space-1) var(--space-2);text-align:right">${w.gewicht_g} g</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Wurf-Formular (neu / bearbeiten)
|
||||
// ----------------------------------------------------------
|
||||
function _showLitterForm(litter) {
|
||||
const isEdit = !!litter;
|
||||
const v = litter || {};
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const body = `
|
||||
<form id="litter-form" autocomplete="off">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Vatername</label>
|
||||
<input class="form-control" type="text" name="vater_name"
|
||||
value="${_esc(v.vater_name || '')}" placeholder="Name des Vaters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Muttername</label>
|
||||
<input class="form-control" type="text" name="mutter_name"
|
||||
value="${_esc(v.mutter_name || '')}" placeholder="Name der Mutter">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geburtsdatum</label>
|
||||
<input class="form-control" type="date" name="geburt_datum"
|
||||
value="${_esc(v.geburt_datum || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Erwartetes Datum</label>
|
||||
<input class="form-control" type="date" name="erwartetes_datum"
|
||||
value="${_esc(v.erwartetes_datum || '')}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Welpen gesamt</label>
|
||||
<input class="form-control" type="number" name="welpen_gesamt" min="0"
|
||||
value="${v.welpen_gesamt != null ? v.welpen_gesamt : ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Welpen verfügbar</label>
|
||||
<input class="form-control" type="number" name="welpen_verfuegbar" min="0"
|
||||
value="${v.welpen_verfuegbar != null ? v.welpen_verfuegbar : ''}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-control" name="status">
|
||||
<option value="geplant" ${v.status === 'geplant' ? 'selected' : ''}>Geplant</option>
|
||||
<option value="geboren" ${v.status === 'geboren' ? 'selected' : ''}>Geboren</option>
|
||||
<option value="verfuegbar" ${v.status === 'verfuegbar' ? 'selected' : ''}>Verfügbar</option>
|
||||
<option value="abgeschlossen" ${v.status === 'abgeschlossen' ? 'selected' : ''}>Abgeschlossen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Preisspanne</label>
|
||||
<input class="form-control" type="text" name="preis_spanne"
|
||||
value="${_esc(v.preis_spanne || '')}" placeholder="z. B. 1.500 – 2.000 €">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<textarea class="form-control" name="beschreibung" rows="3"
|
||||
placeholder="Elternlinie, Besonderheiten, Charakter…">${_esc(v.beschreibung || '')}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Gesundheitstests <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<textarea class="form-control" name="gesundheitstests" rows="2"
|
||||
placeholder="HD, ED, Gentest, Augenkontrolle…">${_esc(v.gesundheitstests || '')}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="sichtbar" value="1" ${v.sichtbar ? 'checked' : ''}>
|
||||
Öffentlich sichtbar
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sichtbar bis <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<input class="form-control" type="date" name="sichtbar_bis"
|
||||
value="${_esc(v.sichtbar_bis || '')}">
|
||||
</div>
|
||||
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="lf-cancel">Abbrechen</button>
|
||||
<button type="submit" form="litter-form" class="btn btn-primary flex-1" id="lf-submit">
|
||||
${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Anlegen`}
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: isEdit ? `${UI.icon('pencil-simple')} Wurf bearbeiten` : `${UI.icon('dog')} Neuer Wurf`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
||||
document.getElementById('lf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('litter-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('lf-submit');
|
||||
const fd = new FormData(e.target);
|
||||
|
||||
const payload = {
|
||||
vater_name: fd.get('vater_name')?.trim() || null,
|
||||
mutter_name: fd.get('mutter_name')?.trim() || null,
|
||||
geburt_datum: fd.get('geburt_datum') || null,
|
||||
erwartetes_datum: fd.get('erwartetes_datum') || null,
|
||||
welpen_gesamt: fd.get('welpen_gesamt') ? parseInt(fd.get('welpen_gesamt')) : null,
|
||||
welpen_verfuegbar: fd.get('welpen_verfuegbar') ? parseInt(fd.get('welpen_verfuegbar')) : null,
|
||||
beschreibung: fd.get('beschreibung')?.trim() || null,
|
||||
gesundheitstests: fd.get('gesundheitstests')?.trim() || null,
|
||||
preis_spanne: fd.get('preis_spanne')?.trim() || null,
|
||||
status: fd.get('status') || 'geplant',
|
||||
sichtbar: fd.get('sichtbar') === '1' ? 1 : 0,
|
||||
sichtbar_bis: fd.get('sichtbar_bis') || null,
|
||||
};
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
if (isEdit) {
|
||||
const updated = await API.litters.update(litter.id, payload);
|
||||
const idx = _litters.findIndex(l => l.id === litter.id);
|
||||
if (idx !== -1) _litters[idx] = updated;
|
||||
UI.toast.success('Wurf aktualisiert.');
|
||||
} else {
|
||||
const created = await API.litters.create(payload);
|
||||
_litters.unshift(created);
|
||||
UI.toast.success('Wurf angelegt.');
|
||||
}
|
||||
UI.modal.close();
|
||||
_renderList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Wurf löschen
|
||||
// ----------------------------------------------------------
|
||||
async function _deleteLitter(litterId) {
|
||||
const litter = _litters.find(l => l.id === litterId);
|
||||
const label = [litter?.vater_name, litter?.mutter_name].filter(Boolean).join(' × ') || `Wurf #${litterId}`;
|
||||
|
||||
if (!window.confirm(`Wurf "${label}" wirklich löschen? Alle Welpen werden ebenfalls gelöscht.`)) return;
|
||||
|
||||
try {
|
||||
await API.litters.remove(litterId);
|
||||
_litters = _litters.filter(l => l.id !== litterId);
|
||||
if (_openId === litterId) _openId = null;
|
||||
_renderList();
|
||||
UI.toast.success('Wurf gelöscht.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Welpen-Formular (neu / bearbeiten)
|
||||
// ----------------------------------------------------------
|
||||
function _showPuppyForm(litterId, puppy) {
|
||||
const isEdit = !!puppy;
|
||||
const v = puppy || {};
|
||||
|
||||
const body = `
|
||||
<form id="puppy-form" autocomplete="off">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
value="${_esc(v.name || '')}" placeholder="z. B. Max">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geschlecht</label>
|
||||
<select class="form-control" name="geschlecht">
|
||||
<option value="" ${!v.geschlecht ? 'selected' : ''}>—</option>
|
||||
<option value="maennlich" ${v.geschlecht === 'maennlich' ? 'selected' : ''}>Männlich</option>
|
||||
<option value="weiblich" ${v.geschlecht === 'weiblich' ? 'selected' : ''}>Weiblich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Farbe / Fellzeichnung</label>
|
||||
<input class="form-control" type="text" name="farbe"
|
||||
value="${_esc(v.farbe || '')}" placeholder="z. B. schwarz-braun">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-control" name="status">
|
||||
<option value="verfuegbar" ${v.status === 'verfuegbar' ? 'selected' : ''}>Verfügbar</option>
|
||||
<option value="reserviert" ${v.status === 'reserviert' ? 'selected' : ''}>Reserviert</option>
|
||||
<option value="abgegeben" ${v.status === 'abgegeben' ? 'selected' : ''}>Abgegeben</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Chip-Nr.</label>
|
||||
<input class="form-control" type="text" name="chip_nr"
|
||||
value="${_esc(v.chip_nr || '')}" placeholder="15-stellig">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geburtsgewicht (g)</label>
|
||||
<input class="form-control" type="number" name="geburtsgewicht" min="0" step="1"
|
||||
value="${v.geburtsgewicht != null ? v.geburtsgewicht : ''}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="status_sichtbar" value="1" ${v.status_sichtbar !== 0 ? 'checked' : ''}>
|
||||
Status öffentlich anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notiz <span style="color:var(--c-text-secondary)">(intern)</span></label>
|
||||
<textarea class="form-control" name="notiz" rows="2"
|
||||
placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="pf-cancel">Abbrechen</button>
|
||||
<button type="submit" form="puppy-form" class="btn btn-primary flex-1" id="pf-submit">
|
||||
${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Hinzufügen`}
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: isEdit ? `${UI.icon('dog')} Welpe bearbeiten` : `${UI.icon('dog')} Welpe hinzufügen`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
||||
document.getElementById('pf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('puppy-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('pf-submit');
|
||||
const fd = new FormData(e.target);
|
||||
|
||||
const payload = {
|
||||
name: fd.get('name')?.trim() || null,
|
||||
geschlecht: fd.get('geschlecht') || null,
|
||||
farbe: fd.get('farbe')?.trim() || null,
|
||||
chip_nr: fd.get('chip_nr')?.trim() || null,
|
||||
geburtsgewicht: fd.get('geburtsgewicht') ? parseFloat(fd.get('geburtsgewicht')) : null,
|
||||
status: fd.get('status') || 'verfuegbar',
|
||||
status_sichtbar: fd.get('status_sichtbar') === '1' ? 1 : 0,
|
||||
notiz: fd.get('notiz')?.trim() || null,
|
||||
};
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
if (isEdit) {
|
||||
await API.litters.updatePuppy(puppy.id, payload);
|
||||
UI.toast.success('Welpe aktualisiert.');
|
||||
} else {
|
||||
await API.litters.addPuppy(litterId, payload);
|
||||
UI.toast.success('Welpe hinzugefügt.');
|
||||
}
|
||||
UI.modal.close();
|
||||
// Welpen-Liste für diesen Wurf neu laden
|
||||
await _loadPuppies(litterId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Kaufvertrag Modal
|
||||
// ----------------------------------------------------------
|
||||
function _showContractModal(puppy) {
|
||||
const puppyLabel = puppy.name || `Welpe #${puppy.id}`;
|
||||
|
||||
const body = `
|
||||
<form id="contract-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name des Käufers <span style="color:var(--c-danger)">*</span></label>
|
||||
<input class="form-control" type="text" name="kaeufer_name" required
|
||||
placeholder="Vor- und Nachname">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Adresse des Käufers <span style="color:var(--c-danger)">*</span></label>
|
||||
<textarea class="form-control" name="kaeufer_adresse" rows="2" required
|
||||
placeholder="Straße, PLZ, Ort"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">E-Mail des Käufers <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<input class="form-control" type="email" name="kaeufer_email"
|
||||
placeholder="kaeufer@beispiel.de">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kaufpreis <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<input class="form-control" type="text" name="preis"
|
||||
placeholder="z. B. 1.500 €">
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="contract-cancel">Abbrechen</button>
|
||||
<button type="submit" form="contract-form" class="btn btn-primary flex-1" id="contract-submit">
|
||||
${UI.icon('file-text')} Vertrag erstellen
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
||||
document.getElementById('contract-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('contract-form')?.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const params = new URLSearchParams({
|
||||
kaeufer_name: fd.get('kaeufer_name')?.trim() || '',
|
||||
kaeufer_adresse: fd.get('kaeufer_adresse')?.trim() || '',
|
||||
kaeufer_email: fd.get('kaeufer_email')?.trim() || '',
|
||||
preis: fd.get('preis')?.trim() || '',
|
||||
});
|
||||
const url = `/api/litters/puppies/${puppy.id}/contract?` + params.toString();
|
||||
window.open(url, '_blank');
|
||||
UI.modal.close();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Foto-Verwaltung Modal
|
||||
// ----------------------------------------------------------
|
||||
async function _showPhotosModal(entityType, entityId, label) {
|
||||
const modalId = 'photos-modal';
|
||||
const galleryId = 'photos-gallery';
|
||||
const uploadFormId = 'photos-upload-form';
|
||||
|
||||
const visLabels = {
|
||||
public: { text: 'Öffentlich', color: 'var(--c-success,#22C55E)' },
|
||||
inquiry: { text: 'Anfrage', color: '#F59E0B' },
|
||||
private: { text: 'Privat', color: 'var(--c-text-muted,#9CA3AF)' },
|
||||
};
|
||||
const visOrder = ['public', 'inquiry', 'private'];
|
||||
|
||||
const body = `
|
||||
<div id="${galleryId}" style="margin-bottom:var(--space-4)">
|
||||
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</p>
|
||||
</div>
|
||||
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
|
||||
<form id="${uploadFormId}" style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
|
||||
${UI.icon('upload-simple')} Foto hochladen
|
||||
</label>
|
||||
<input class="form-control" type="file" name="file" accept="image/*,.pdf" required>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="submit" form="${uploadFormId}" class="btn btn-primary" id="photos-upload-btn">
|
||||
${UI.icon('upload-simple')} Hochladen
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('images')} Fotos — ${_esc(label)}`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
||||
// Galerie laden
|
||||
async function _loadGallery() {
|
||||
const el = document.getElementById(galleryId);
|
||||
if (!el) return;
|
||||
try {
|
||||
const photos = await API.breederPhotos.list(entityType, entityId);
|
||||
if (!photos.length) {
|
||||
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos vorhanden.</p>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:var(--space-2)">
|
||||
${photos.map(ph => {
|
||||
const thumb = ph.thumbnail_url || ph.url || '';
|
||||
const vis = visLabels[ph.visibility] || visLabels.private;
|
||||
return `
|
||||
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--c-border);aspect-ratio:1">
|
||||
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
|
||||
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
|
||||
loading="lazy"
|
||||
style="width:100%;height:100%;object-fit:cover;display:block"
|
||||
onerror="this.src='/static/img/placeholder.webp'">
|
||||
</a>
|
||||
<button class="photos-vis-btn"
|
||||
data-photo-id="${ph.id}"
|
||||
data-vis="${_esc(ph.visibility)}"
|
||||
title="Sichtbarkeit ändern"
|
||||
style="position:absolute;bottom:0;left:0;right:0;
|
||||
background:${vis.color};color:#fff;
|
||||
border:none;cursor:pointer;font-size:10px;padding:2px 4px;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(vis.text)}
|
||||
</button>
|
||||
<button class="photos-del-btn"
|
||||
data-photo-id="${ph.id}"
|
||||
title="Foto löschen"
|
||||
style="position:absolute;top:2px;right:2px;
|
||||
background:rgba(0,0,0,.55);color:#fff;
|
||||
border:none;border-radius:50%;cursor:pointer;
|
||||
width:22px;height:22px;display:flex;align-items:center;justify-content:center;font-size:12px">
|
||||
${UI.icon('x')}
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
|
||||
// Sichtbarkeit rotieren
|
||||
el.querySelectorAll('.photos-vis-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const photoId = parseInt(btn.dataset.photoId);
|
||||
const cur = btn.dataset.vis;
|
||||
const next = visOrder[(visOrder.indexOf(cur) + 1) % visOrder.length];
|
||||
try {
|
||||
await API.breederPhotos.updateVisibility(photoId, next);
|
||||
_loadGallery();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Ändern der Sichtbarkeit.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Löschen
|
||||
el.querySelectorAll('.photos-del-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const photoId = parseInt(btn.dataset.photoId);
|
||||
if (!window.confirm('Foto wirklich löschen?')) return;
|
||||
try {
|
||||
await API.breederPhotos.remove(photoId);
|
||||
_loadGallery();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const el = document.getElementById(galleryId);
|
||||
if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
_loadGallery();
|
||||
|
||||
// Upload
|
||||
document.getElementById(uploadFormId)?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('photos-upload-btn');
|
||||
const fd = new FormData(e.target);
|
||||
const fileInput = e.target.querySelector('[name="file"]');
|
||||
|
||||
if (!fileInput?.files?.length) {
|
||||
UI.toast.error('Bitte eine Datei auswählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadFd = new FormData();
|
||||
uploadFd.append('entity_type', entityType);
|
||||
uploadFd.append('entity_id', String(entityId));
|
||||
uploadFd.append('visibility', 'public');
|
||||
uploadFd.append('file', fileInput.files[0]);
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.breederPhotos.upload(uploadFd);
|
||||
UI.toast.success('Foto hochgeladen.');
|
||||
e.target.reset();
|
||||
await _loadGallery();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -54,6 +54,7 @@ window.Page_map = (() => {
|
|||
parkplatz: [],
|
||||
treffpunkt: [],
|
||||
community: [],
|
||||
zuechter: [],
|
||||
};
|
||||
|
||||
const VISIBLE_KEY = 'by_map_visible_v1';
|
||||
|
|
@ -89,6 +90,7 @@ window.Page_map = (() => {
|
|||
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
|
||||
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
|
||||
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
|
||||
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
|
||||
};
|
||||
|
||||
// Frontend-Layer → Backend-Typ Mapping
|
||||
|
|
@ -998,13 +1000,15 @@ window.Page_map = (() => {
|
|||
(_layers.poison || []).forEach(m => m._dangerCircle?.remove());
|
||||
Object.keys(_layers).forEach(k => { _layers[k] = []; });
|
||||
|
||||
const [places, poisonList] = await Promise.allSettled([
|
||||
const [places, poisonList, breederList] = await Promise.allSettled([
|
||||
API.places.list(),
|
||||
_userPos ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) : Promise.resolve([]),
|
||||
API.breeder.mapMarkers(),
|
||||
]);
|
||||
|
||||
if (places.status === 'fulfilled') _addPlaces(places.value);
|
||||
if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
|
||||
if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
|
||||
_scheduleOsmLoad();
|
||||
}
|
||||
|
||||
|
|
@ -1039,6 +1043,59 @@ window.Page_map = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
function _addBreeders(breeders) {
|
||||
if (!_map || !window.L) return;
|
||||
const t = TYPEN.zuechter;
|
||||
const cluster = _getCluster('zuechter');
|
||||
const markers = [];
|
||||
|
||||
breeders.forEach(b => {
|
||||
// Ohne Koordinaten: stillen Skip
|
||||
if (b.location_lat == null || b.location_lng == null) return;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="background:${t.color};color:#fff;font-size:15px;
|
||||
width:32px;height:32px;border-radius:50%;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
||||
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||
});
|
||||
|
||||
const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 })
|
||||
.bindTooltip(_esc(b.zwingername), { direction: 'top', offset: [0, -16] });
|
||||
|
||||
marker.on('click', () => {
|
||||
const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${_esc(b.rasse_text)}</div>` : '';
|
||||
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${_esc(b.stadt)}</div>` : '';
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="min-width:170px;max-width:240px">
|
||||
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${_esc(b.zwingername)}</div>
|
||||
${rasseText}${stadtText}
|
||||
<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button>
|
||||
</div>
|
||||
`, { maxWidth: 260 }).openPopup();
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('breeder-profile-btn')?.addEventListener('click', () => {
|
||||
marker.closePopup();
|
||||
App.navigate('breeder', true, { zwingername: b.zwingername });
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
|
||||
markers.push(marker);
|
||||
_layers.zuechter.push(marker);
|
||||
});
|
||||
|
||||
cluster.addLayers(markers);
|
||||
if (_visible.zuechter !== false && _map && !_map.hasLayer(cluster)) {
|
||||
cluster.addTo(_map);
|
||||
}
|
||||
}
|
||||
|
||||
function _createSimpleMarker(lat, lon, t, tooltip, onClick) {
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
|
|
|
|||
|
|
@ -194,6 +194,9 @@ window.Page_settings = (() => {
|
|||
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- Züchter-Profil Slot -->
|
||||
<div id="breeder-card-slot"></div>
|
||||
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
|
||||
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
|
||||
|
|
@ -679,6 +682,280 @@ window.Page_settings = (() => {
|
|||
});
|
||||
|
||||
_loadReferral();
|
||||
_loadBreederCard();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER-CARD — asynchron laden und in Slot rendern
|
||||
// ----------------------------------------------------------
|
||||
async function _loadBreederCard() {
|
||||
const slot = document.getElementById('breeder-card-slot');
|
||||
if (!slot) return;
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
status = await API.breeder.status();
|
||||
} catch {
|
||||
// API nicht verfügbar — Card weglassen
|
||||
return;
|
||||
}
|
||||
|
||||
const { rolle, breeder_status, profile } = status;
|
||||
|
||||
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) 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-surface);color:var(--c-text)`;
|
||||
|
||||
let statusBadge = '';
|
||||
let actionBlock = '';
|
||||
|
||||
if (rolle === 'breeder' || rolle === 'admin') {
|
||||
statusBadge = `<span class="badge badge-primary" style="background:var(--c-success);color:#fff">
|
||||
${UI.icon('check-circle')} ${rolle === 'admin' ? 'Admin — alle Züchter-Features verfügbar' : 'Verifizierter Züchter'}
|
||||
</span>`;
|
||||
actionBlock = `
|
||||
<div style="margin-top:var(--space-3);font-size:var(--text-sm);display:flex;flex-direction:column;gap:var(--space-1)">
|
||||
${profile?.zwingername ? `<div style="color:var(--c-text-secondary)">Zwinger: <strong>${_esc(profile.zwingername)}</strong></div>` : ''}
|
||||
${profile?.rasse_text ? `<div style="color:var(--c-text-secondary)">Rasse: <strong>${_esc(profile.rasse_text)}</strong></div>` : ''}
|
||||
</div>
|
||||
${rolle === 'breeder' && profile ? `
|
||||
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
|
||||
${UI.icon('pencil-simple')} Profil bearbeiten
|
||||
</button>` : ''}`;
|
||||
} else if (breeder_status === 'pending') {
|
||||
statusBadge = `<span class="badge" style="background:#f59e0b;color:#fff">
|
||||
${UI.icon('hourglass')} Antrag wird geprüft
|
||||
</span>`;
|
||||
} else if (breeder_status === 'rejected') {
|
||||
statusBadge = `<span class="badge" style="background:var(--c-danger);color:#fff">
|
||||
${UI.icon('x-circle')} Abgelehnt
|
||||
</span>`;
|
||||
actionBlock = `
|
||||
<div style="margin-top:var(--space-3)">
|
||||
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
|
||||
${UI.icon('arrow-counter-clockwise')} Neu beantragen
|
||||
</button>
|
||||
</div>`;
|
||||
} else {
|
||||
actionBlock = `
|
||||
<div style="margin-top:var(--space-3)">
|
||||
<button class="btn btn-primary btn-sm" id="breeder-apply-btn">
|
||||
${UI.icon('certificate')} Züchter werden
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
slot.innerHTML = `
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-3) var(--space-4);
|
||||
font-size:var(--text-xs);font-weight:600;
|
||||
color:var(--c-text-secondary);text-transform:uppercase;
|
||||
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
|
||||
Züchter-Profil
|
||||
</div>
|
||||
<div style="padding:var(--space-4)">
|
||||
${statusBadge}
|
||||
${actionBlock}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Button-Handler binden
|
||||
const applyBtn = slot.querySelector('#breeder-apply-btn');
|
||||
const reapplyBtn = slot.querySelector('#breeder-reapply-btn');
|
||||
if (applyBtn || reapplyBtn) {
|
||||
(applyBtn || reapplyBtn).addEventListener('click', () => _openBreederApplyModal());
|
||||
}
|
||||
|
||||
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
|
||||
_openBreederEditModal(profile)
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER-PROFIL BEARBEITEN MODAL
|
||||
// ----------------------------------------------------------
|
||||
function _openBreederEditModal(profile) {
|
||||
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) 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-surface);color:var(--c-text)`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('pencil-simple')} Züchter-Profil bearbeiten`,
|
||||
body: `
|
||||
<form id="breeder-edit-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zwingername</label>
|
||||
<input name="zwingername" type="text" maxlength="100" style="${inputStyle}"
|
||||
value="${_esc(profile?.zwingername || '')}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Rasse</label>
|
||||
<input name="rasse_text" type="text" maxlength="100" style="${inputStyle}"
|
||||
value="${_esc(profile?.rasse_text || '')}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zuchtverein</label>
|
||||
<input name="verein" type="text" maxlength="100" style="${inputStyle}"
|
||||
value="${_esc(profile?.verein || '')}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Stadt</label>
|
||||
<input name="stadt" type="text" maxlength="80" style="${inputStyle}"
|
||||
value="${_esc(profile?.stadt || '')}">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<input name="vdh_mitglied" type="checkbox" id="edit-breeder-vdh"
|
||||
style="width:18px;height:18px;cursor:pointer;flex-shrink:0"
|
||||
${profile?.vdh_mitglied ? 'checked' : ''}>
|
||||
<label for="edit-breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">VDH-Mitglied</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Website (optional)</label>
|
||||
<input name="website" type="url" maxlength="200" style="${inputStyle}"
|
||||
value="${_esc(profile?.website || '')}" placeholder="https://mein-zwinger.de">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Beschreibung (optional)</label>
|
||||
<textarea name="beschreibung" maxlength="500" rows="3"
|
||||
style="${inputStyle};resize:vertical">${_esc(profile?.beschreibung || '')}</textarea>
|
||||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="breeder-edit-form" class="btn btn-primary flex-1" id="breeder-edit-submit">Speichern</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
document.getElementById('breeder-edit-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('breeder-edit-submit');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const form = e.target;
|
||||
const data = {
|
||||
zwingername: form.zwingername.value.trim() || undefined,
|
||||
rasse_text: form.rasse_text.value.trim() || undefined,
|
||||
verein: form.verein.value.trim() || undefined,
|
||||
stadt: form.stadt.value.trim() || undefined,
|
||||
vdh_mitglied: form.vdh_mitglied.checked ? 1 : 0,
|
||||
website: form.website.value.trim() || undefined,
|
||||
beschreibung: form.beschreibung.value.trim() || undefined,
|
||||
};
|
||||
await API.breeder.updateProfile(data);
|
||||
UI.modal.close?.();
|
||||
UI.toast.success('Profil aktualisiert.');
|
||||
_loadBreederCard();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER-ANTRAG MODAL
|
||||
// ----------------------------------------------------------
|
||||
function _openBreederApplyModal() {
|
||||
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) 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-surface);color:var(--c-text)`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('certificate')} Züchter-Antrag stellen`,
|
||||
body: `
|
||||
<form id="breeder-apply-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Zwingername <span style="color:var(--c-danger)">*</span>
|
||||
</label>
|
||||
<input name="zwingername" type="text" maxlength="100" required
|
||||
placeholder="z. B. vom Sonnenfeld"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Rasse <span style="color:var(--c-danger)">*</span>
|
||||
</label>
|
||||
<input name="rasse_text" type="text" maxlength="100" required
|
||||
placeholder="z. B. Labrador Retriever"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Zuchtverein <span style="color:var(--c-danger)">*</span>
|
||||
</label>
|
||||
<input name="verein" type="text" maxlength="100" required
|
||||
placeholder="z. B. DLRG, VDH, BCD"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Stadt <span style="color:var(--c-danger)">*</span>
|
||||
</label>
|
||||
<input name="stadt" type="text" maxlength="80" required
|
||||
placeholder="z. B. München"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<input name="vdh_mitglied" type="checkbox" id="breeder-vdh"
|
||||
style="width:18px;height:18px;cursor:pointer;flex-shrink:0">
|
||||
<label for="breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">
|
||||
VDH-Mitglied
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Website (optional)
|
||||
</label>
|
||||
<input name="website" type="url" maxlength="200"
|
||||
placeholder="https://mein-zwinger.de"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea name="beschreibung" maxlength="500" rows="3"
|
||||
placeholder="Kurze Beschreibung deines Zwingers"
|
||||
style="${inputStyle};resize:vertical"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Dokument hochladen <span style="color:var(--c-danger)">*</span>
|
||||
</label>
|
||||
<input name="dokument" type="file" id="breeder-doc-input" required
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
style="font-size:var(--text-sm);width:100%;box-sizing:border-box">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
|
||||
Zuchtbuch-Eintrag, Vereinsmitgliedschaft o.ä. (PDF, JPG, PNG, WebP)
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="breeder-apply-form" class="btn btn-primary" id="breeder-apply-submit"
|
||||
style="width:100%">Antrag einreichen</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('breeder-apply-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('breeder-apply-submit');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const form = e.target;
|
||||
const fd = new FormData(form);
|
||||
// Checkbox-Wert normalisieren
|
||||
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
|
||||
await API.breeder.apply(fd);
|
||||
UI.modal.close?.();
|
||||
UI.toast.success('Antrag eingereicht. Du wirst benachrichtigt sobald er geprüft wurde.');
|
||||
// Card neu laden
|
||||
_loadBreederCard();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
280
backend/static/js/pages/wurfboerse.js
Normal file
280
backend/static/js/pages/wurfboerse.js
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Wurfbörse
|
||||
Öffentliche Wurfankündigungen aller Züchter
|
||||
============================================================ */
|
||||
|
||||
window.Page_wurfboerse = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _data = [];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso + 'T12:00:00');
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function _statusBadge(status) {
|
||||
const map = {
|
||||
geplant: { label: 'Geplant', cls: 'wb-badge--geplant' },
|
||||
geboren: { label: 'Geboren', cls: 'wb-badge--geboren' },
|
||||
verfuegbar: { label: 'Verfügbar', cls: 'wb-badge--verfuegbar' },
|
||||
abgeschlossen: { label: 'Abgeschlossen', cls: 'wb-badge--abgeschlossen' },
|
||||
};
|
||||
const s = map[status] || { label: status, cls: 'wb-badge--geplant' };
|
||||
return `<span class="wb-badge ${s.cls}">${s.label}</span>`;
|
||||
}
|
||||
|
||||
function _truncate(text, max) {
|
||||
if (!text) return '';
|
||||
return text.length > max ? text.slice(0, max).trimEnd() + '…' : text;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
function refresh() { _loadData(); }
|
||||
function onDogChange() {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Grundstruktur rendern
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="wb-layout">
|
||||
|
||||
<!-- Filter-Leiste -->
|
||||
<div class="wb-filter-bar">
|
||||
<div class="wb-filter-fields">
|
||||
<input
|
||||
class="form-control wb-filter-rasse"
|
||||
id="wb-filter-rasse"
|
||||
type="text"
|
||||
placeholder="Rasse suchen…"
|
||||
autocomplete="off"
|
||||
>
|
||||
<select class="form-control wb-filter-status" id="wb-filter-status">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="geplant">Geplant</option>
|
||||
<option value="verfuegbar">Verfügbar</option>
|
||||
<option value="geboren">Geboren</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary wb-filter-btn" id="wb-search-btn">
|
||||
${UI.icon('magnifying-glass')} Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ergebnisliste -->
|
||||
<div id="wb-list">
|
||||
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Suchen-Button
|
||||
document.getElementById('wb-search-btn').addEventListener('click', () => _loadData());
|
||||
|
||||
// Enter im Rasse-Feld
|
||||
document.getElementById('wb-filter-rasse').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') _loadData();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten laden
|
||||
// ----------------------------------------------------------
|
||||
async function _loadData() {
|
||||
const rasseEl = document.getElementById('wb-filter-rasse');
|
||||
const statusEl = document.getElementById('wb-filter-status');
|
||||
|
||||
const params = {};
|
||||
if (rasseEl?.value.trim()) params.rasse = rasseEl.value.trim();
|
||||
if (statusEl?.value) params.status = statusEl.value;
|
||||
|
||||
try {
|
||||
_data = await API.litters.public(params);
|
||||
_renderList();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Laden der Wurfbörse.');
|
||||
_renderEmpty('Fehler beim Laden', 'Bitte später erneut versuchen.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Liste rendern
|
||||
// ----------------------------------------------------------
|
||||
function _renderList() {
|
||||
const el = document.getElementById('wb-list');
|
||||
if (!el) return;
|
||||
|
||||
if (!_data.length) {
|
||||
_renderEmpty('Keine Würfe gefunden', 'Für die gewählten Filter gibt es aktuell keine Wurfankündigungen.');
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `<div class="wb-cards">${_data.map(b => _cardHTML(b)).join('')}</div>`;
|
||||
|
||||
el.querySelectorAll('.wb-profile-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const zwingername = btn.dataset.zwingername;
|
||||
App.navigate('breeder', true, { zwingername });
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.wb-chat-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const breederId = parseInt(btn.dataset.breederUserId, 10);
|
||||
_contactBreeder(breederId);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.wb-login-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => App.navigate('settings'));
|
||||
});
|
||||
}
|
||||
|
||||
function _renderEmpty(title, text) {
|
||||
const el = document.getElementById('wb-list');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
|
||||
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
|
||||
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Card HTML
|
||||
// ----------------------------------------------------------
|
||||
function _cardHTML(b) {
|
||||
// Züchter-Kopfzeile
|
||||
const zuechterName = b.zuechter_name || b.zwingername || '—';
|
||||
const zwingername = b.zwingername ? ` (${_esc(b.zwingername)})` : '';
|
||||
const stadtLine = b.stadt ? ` · ${_esc(b.stadt)}` : '';
|
||||
|
||||
// Elterntiere
|
||||
const elternParts = [];
|
||||
if (b.vater_name) elternParts.push(_esc(b.vater_name));
|
||||
if (b.mutter_name) elternParts.push(_esc(b.mutter_name));
|
||||
const elternLine = elternParts.length === 2
|
||||
? `<div class="wb-card-eltern">${UI.icon('gender-male')} ${elternParts[0]} × ${UI.icon('gender-female')} ${elternParts[1]}</div>`
|
||||
: elternParts.length === 1
|
||||
? `<div class="wb-card-eltern">${elternParts[0]}</div>`
|
||||
: '';
|
||||
|
||||
// Datum
|
||||
let datumLine = '';
|
||||
if (b.geburt_datum) {
|
||||
datumLine = `<div class="wb-card-datum">${UI.icon('calendar-dots')} Geboren: ${_fmtDate(b.geburt_datum)}</div>`;
|
||||
} else if (b.erwartetes_datum) {
|
||||
datumLine = `<div class="wb-card-datum">${UI.icon('calendar-dots')} Erwartet: ${_fmtDate(b.erwartetes_datum)}</div>`;
|
||||
}
|
||||
|
||||
// Welpen-Verfügbarkeit
|
||||
let welpenLine = '';
|
||||
if (b.welpen_gesamt != null || b.welpen_verfuegbar != null) {
|
||||
const gesamt = b.welpen_gesamt != null ? b.welpen_gesamt : '?';
|
||||
const verfuegb = b.welpen_verfuegbar != null ? b.welpen_verfuegbar : '?';
|
||||
welpenLine = `<div class="wb-card-welpen">${UI.icon('paw-print')} Welpen verfügbar: ${_esc(String(verfuegb))} von ${_esc(String(gesamt))}</div>`;
|
||||
}
|
||||
|
||||
// Preis
|
||||
const preisLine = b.preis_spanne
|
||||
? `<div class="wb-card-preis">${UI.icon('currency-eur')} Preis: ${_esc(b.preis_spanne)} €</div>`
|
||||
: '';
|
||||
|
||||
// Gesundheitstests
|
||||
const gesundheitLine = b.gesundheitstests
|
||||
? `<div class="wb-card-gesundheit">${UI.icon('heart')} ${_esc(b.gesundheitstests)}</div>`
|
||||
: '';
|
||||
|
||||
// Beschreibung (max. 150 Zeichen)
|
||||
const beschreibungLine = b.beschreibung
|
||||
? `<div class="wb-card-beschreibung">${_esc(_truncate(b.beschreibung, 150))}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="wb-card">
|
||||
<div class="wb-card-header">
|
||||
<div class="wb-card-zuechter">
|
||||
${_esc(zuechterName)}${zwingername}${stadtLine}
|
||||
</div>
|
||||
${_statusBadge(b.status)}
|
||||
</div>
|
||||
|
||||
${b.rasse_text ? `<div class="wb-card-rasse">${UI.icon('dog')} ${_esc(b.rasse_text)}</div>` : ''}
|
||||
|
||||
<div class="wb-card-details">
|
||||
${elternLine}
|
||||
${datumLine}
|
||||
${welpenLine}
|
||||
${preisLine}
|
||||
${gesundheitLine}
|
||||
${beschreibungLine}
|
||||
</div>
|
||||
|
||||
<div class="wb-card-footer">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm wb-profile-btn"
|
||||
data-zwingername="${_esc(b.zwingername || '')}"
|
||||
>
|
||||
${UI.icon('user')} Profil ansehen
|
||||
</button>
|
||||
${(() => {
|
||||
const isLoggedIn = !!_appState?.user;
|
||||
const isOwnProfile = _appState?.user?.id === b.breeder_user_id;
|
||||
if (isOwnProfile) return '';
|
||||
if (isLoggedIn) {
|
||||
return `<button
|
||||
class="btn btn-primary btn-sm wb-chat-btn"
|
||||
data-breeder-user-id="${b.breeder_user_id || ''}"
|
||||
>
|
||||
${UI.icon('chat-circle')} Nachricht senden
|
||||
</button>`;
|
||||
}
|
||||
return `<button class="btn btn-primary btn-sm wb-login-btn">
|
||||
${UI.icon('sign-in')} Anmelden um zu schreiben
|
||||
</button>`;
|
||||
})()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Züchter per Chat kontaktieren
|
||||
// ----------------------------------------------------------
|
||||
async function _contactBreeder(breederId) {
|
||||
if (!_appState?.user) {
|
||||
App.navigate('settings');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conv = await API.chat.start(breederId);
|
||||
App.navigate('chat');
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.');
|
||||
}
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
718
backend/static/js/pages/zucht-profil.js
Normal file
718
backend/static/js/pages/zucht-profil.js
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Zucht-Profil
|
||||
Vollständiges Profil eines Zuchthundes:
|
||||
Basisdaten + Stammbaum (4 Generationen) + Gesundheitstests
|
||||
+ Gentests + Titel.
|
||||
============================================================ */
|
||||
|
||||
window.Page_zucht_profil = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _hundId = null;
|
||||
let _hund = null;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso + 'T12:00:00');
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Badge-Farben
|
||||
// ----------------------------------------------------------
|
||||
function _healthBadge(testTyp, ergebnis) {
|
||||
const e = (ergebnis || '').trim().toUpperCase();
|
||||
let color = '#6B7280';
|
||||
|
||||
if (testTyp === 'HD') {
|
||||
if (['A1', 'A2', 'A'].includes(e)) color = '#22C55E';
|
||||
else if (['B1', 'B2', 'B'].includes(e)) color = '#86EFAC';
|
||||
else if (e === 'C') color = '#EAB308';
|
||||
else if (e === 'D') color = '#F97316';
|
||||
else if (e === 'E') color = '#EF4444';
|
||||
} else if (testTyp === 'ED') {
|
||||
if (e === '0' || e === 'ED 0') color = '#22C55E';
|
||||
else if (e === '1' || e === 'ED 1') color = '#EAB308';
|
||||
else if (e === '2' || e === 'ED 2') color = '#F97316';
|
||||
else if (e === '3' || e === 'ED 3') color = '#EF4444';
|
||||
} else {
|
||||
const el = e.toLowerCase();
|
||||
if (el === 'clear') color = '#22C55E';
|
||||
if (el === 'carrier') color = '#EAB308';
|
||||
if (el === 'affected') color = '#EF4444';
|
||||
}
|
||||
|
||||
return `<span class="zp-badge" style="background:${color}">${_esc(ergebnis || '—')}</span>`;
|
||||
}
|
||||
|
||||
function _geneticBadge(ergebnis) {
|
||||
const e = (ergebnis || '').toLowerCase().trim();
|
||||
let color = '#6B7280';
|
||||
if (e === 'clear') color = '#22C55E';
|
||||
if (e === 'carrier') color = '#F59E0B';
|
||||
if (e === 'affected') color = '#EF4444';
|
||||
return `<span class="zp-badge" style="background:${color}">${_esc(ergebnis || '—')}</span>`;
|
||||
}
|
||||
|
||||
function _titleTypBadge(typ) {
|
||||
const t = (typ || '').toLowerCase();
|
||||
const colors = {
|
||||
ausstellung: '#8B5CF6',
|
||||
arbeit: '#F59E0B',
|
||||
champion: '#EF4444',
|
||||
sport: '#3B82F6',
|
||||
zucht: '#10B981',
|
||||
};
|
||||
const color = colors[t] || '#6B7280';
|
||||
return `<span class="zp-badge" style="background:${color}">${_esc(typ || '—')}</span>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT / LIFECYCLE
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState, params) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_hundId = params?.id ? parseInt(params.id) : null;
|
||||
|
||||
if (!_hundId) {
|
||||
_container.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('warning')}</div>
|
||||
<p style="color:var(--c-text-secondary)">Kein Hund angegeben.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
_renderSkeleton();
|
||||
await _load();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (_hundId) _load();
|
||||
}
|
||||
|
||||
function onDogChange() {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten laden
|
||||
// ----------------------------------------------------------
|
||||
async function _load() {
|
||||
try {
|
||||
const [hund, tree, health, genetic, titles] = await Promise.all([
|
||||
API.zuchthunde.get(_hundId),
|
||||
API.zuchthunde.pedigree(_hundId, 4),
|
||||
API.zuchthunde.healthTests(_hundId),
|
||||
API.zuchthunde.geneticTests(_hundId),
|
||||
API.zuchthunde.titles(_hundId),
|
||||
]);
|
||||
_hund = hund;
|
||||
_renderAll(hund, tree, health, genetic, titles);
|
||||
} catch (err) {
|
||||
_container.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('warning')}</div>
|
||||
<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Laden.')}</p>
|
||||
<button class="btn btn-secondary" onclick="history.back()">Zurück</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Skeleton während des Ladens
|
||||
// ----------------------------------------------------------
|
||||
function _renderSkeleton() {
|
||||
_container.innerHTML = `
|
||||
<div class="zp-layout">
|
||||
<button class="btn btn-ghost btn-sm zp-back-btn" style="margin-bottom:var(--space-4)">
|
||||
${UI.icon('arrow-left')} Zurück zur Zuchtkartei
|
||||
</button>
|
||||
${UI.skeleton(6)}
|
||||
</div>`;
|
||||
_container.querySelector('.zp-back-btn')?.addEventListener('click', () => {
|
||||
if (window.history.length > 1) history.back();
|
||||
else App.navigate('zuchthunde');
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Vollständige Seite rendern
|
||||
// ----------------------------------------------------------
|
||||
function _renderAll(hund, tree, health, genetic, titles) {
|
||||
_container.innerHTML = `
|
||||
<div class="zp-layout">
|
||||
|
||||
<!-- Zurück + Link teilen -->
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-4)">
|
||||
<button class="btn btn-ghost btn-sm zp-back-btn">
|
||||
${UI.icon('arrow-left')} Zurück zur Zuchtkartei
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm zp-share-btn" title="Link teilen">
|
||||
${UI.icon('link-simple')} Link teilen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
${_renderHeader(hund)}
|
||||
|
||||
<!-- Stammbaum -->
|
||||
<div class="zp-section">
|
||||
<h3 class="zp-section-title">${UI.icon('tree-structure')} Stammbaum</h3>
|
||||
<div class="zp-pedigree-wrap">
|
||||
${_renderPedigree(tree, 4)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gesundheitstests -->
|
||||
<div class="zp-section">
|
||||
<h3 class="zp-section-title">${UI.icon('heart')} Gesundheitstests</h3>
|
||||
${_renderHealthTable(health)}
|
||||
</div>
|
||||
|
||||
<!-- Gentests -->
|
||||
<div class="zp-section">
|
||||
<h3 class="zp-section-title">${UI.icon('dna')} Gentests</h3>
|
||||
${_renderGeneticTable(genetic)}
|
||||
</div>
|
||||
|
||||
<!-- Titel -->
|
||||
<div class="zp-section">
|
||||
<h3 class="zp-section-title">${UI.icon('trophy')} Titel & Auszeichnungen</h3>
|
||||
${_renderTitlesList(titles)}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
|
||||
// Zurück-Button verdrahten
|
||||
_container.querySelector('.zp-back-btn')?.addEventListener('click', () => {
|
||||
if (window.history.length > 1) history.back();
|
||||
else App.navigate('zuchthunde');
|
||||
});
|
||||
|
||||
// Link teilen
|
||||
_container.querySelector('.zp-share-btn')?.addEventListener('click', () => {
|
||||
const url = window.location.origin + '#zucht-profil&id=' + _hundId;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
UI.toast.success('Link kopiert!');
|
||||
}).catch(() => {
|
||||
UI.toast.error('Kopieren nicht möglich.');
|
||||
});
|
||||
});
|
||||
|
||||
// Stammbaum-Klicks verdrahten (außer Gen 1 = Proband selbst)
|
||||
_container.querySelectorAll('.pedigree-cell[data-hund-id]').forEach(cell => {
|
||||
const nodeId = parseInt(cell.dataset.hundId);
|
||||
const gen = parseInt(cell.dataset.gen || '1');
|
||||
if (gen === 1) return; // Proband — kein Klick nötig
|
||||
cell.style.cursor = 'pointer';
|
||||
cell.addEventListener('click', () => {
|
||||
App.navigate('zucht-profil', true, { id: nodeId });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Header
|
||||
// ----------------------------------------------------------
|
||||
function _renderHeader(h) {
|
||||
const gIcon = h.geschlecht === 'maennlich' ? UI.icon('gender-male') :
|
||||
h.geschlecht === 'weiblich' ? UI.icon('gender-female') : UI.icon('dog');
|
||||
|
||||
const geburtsjahrLabel = h.geburtsdatum
|
||||
? `*${new Date(h.geburtsdatum + 'T12:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`
|
||||
: null;
|
||||
|
||||
const geschlechtLabel = h.geschlecht === 'maennlich' ? 'Rüde' :
|
||||
h.geschlecht === 'weiblich' ? 'Hündin' : null;
|
||||
|
||||
const metaItems = [
|
||||
h.rasse ? `${UI.icon('paw-print')} ${_esc(h.rasse)}` : null,
|
||||
geschlechtLabel ? `${gIcon} ${geschlechtLabel}` : null,
|
||||
geburtsjahrLabel ? `${UI.icon('calendar-dots')} ${geburtsjahrLabel}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const identItems = [
|
||||
h.chip_nr ? `${UI.icon('barcode')} Chip: ${_esc(h.chip_nr)}` : null,
|
||||
h.zuchtbuchnummer ? `${UI.icon('book-open')} ZB-Nr.: ${_esc(h.zuchtbuchnummer)}` : null,
|
||||
h.taetowier_nr ? `${UI.icon('pencil-simple')} Tätowierung: ${_esc(h.taetowier_nr)}` : null,
|
||||
h.farbe ? `${UI.icon('palette')} ${_esc(h.farbe)}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const elternItems = [
|
||||
h.vater_name ? `Vater: ${_esc(h.vater_name)}` : null,
|
||||
h.mutter_name ? `Mutter: ${_esc(h.mutter_name)}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="zp-header">
|
||||
<div class="zp-header-icon">${gIcon}</div>
|
||||
<div class="zp-header-body">
|
||||
<h2 class="zp-header-name">
|
||||
${_esc(h.name)}
|
||||
${h.rufname ? `<span class="zp-header-rufname">(${_esc(h.rufname)})</span>` : ''}
|
||||
</h2>
|
||||
${metaItems.length ? `
|
||||
<div class="zp-header-meta">
|
||||
${metaItems.map(m => `<span>${m}</span>`).join('<span class="zp-meta-sep">·</span>')}
|
||||
</div>` : ''}
|
||||
${identItems.length ? `
|
||||
<div class="zp-header-meta zp-header-ident">
|
||||
${identItems.map(m => `<span>${m}</span>`).join('')}
|
||||
</div>` : ''}
|
||||
${elternItems.length ? `
|
||||
<div class="zp-header-meta" style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${elternItems.join(' · ')}
|
||||
</div>` : ''}
|
||||
${h.notiz ? `<div class="zp-header-notiz">${_esc(h.notiz)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Stammbaum
|
||||
// ----------------------------------------------------------
|
||||
function _renderPedigree(tree, generations) {
|
||||
const totalRows = Math.pow(2, generations - 1); // 8 für 4 Generationen
|
||||
|
||||
// Alle Knoten rekursiv einsammeln
|
||||
function collect(node, gen, rowStart, rowSpan) {
|
||||
if (gen > generations) return [];
|
||||
const items = [{ node: node || null, gen, rowStart, rowSpan }];
|
||||
if (gen < generations) {
|
||||
const half = rowSpan / 2;
|
||||
items.push(...collect(node?.vater || null, gen + 1, rowStart, half));
|
||||
items.push(...collect(node?.mutter || null, gen + 1, rowStart + half, half));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const items = collect(tree, 1, 1, totalRows);
|
||||
|
||||
const cells = items.map(({ node, gen, rowStart, rowSpan }) => {
|
||||
const isEmpty = !node;
|
||||
return `
|
||||
<div class="pedigree-cell ${isEmpty ? 'pedigree-empty' : ''}"
|
||||
style="grid-column:${gen}; grid-row:${rowStart} / span ${rowSpan};
|
||||
align-items:center; display:flex;"
|
||||
data-gen="${gen}"
|
||||
${node ? `data-hund-id="${node.id}"` : ''}>
|
||||
${node ? _pedigreeNodeHTML(node, gen) : `<div class="pedigree-unknown">${UI.icon('question')}</div>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="pedigree-grid"
|
||||
style="
|
||||
display:grid;
|
||||
grid-template-columns:repeat(${generations}, minmax(160px, 1fr));
|
||||
grid-template-rows:repeat(${totalRows}, minmax(56px, auto));
|
||||
gap:4px;
|
||||
min-width:${generations * 170}px;
|
||||
">
|
||||
${cells}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _pedigreeNodeHTML(node, gen) {
|
||||
const gIcon = node.geschlecht === 'maennlich' ? UI.icon('gender-male') :
|
||||
node.geschlecht === 'weiblich' ? UI.icon('gender-female') : '';
|
||||
const dob = node.geburtsdatum
|
||||
? `*${new Date(node.geburtsdatum + 'T12:00:00').getFullYear()}`
|
||||
: '';
|
||||
|
||||
const isProband = gen === 1;
|
||||
const bgColor = isProband ? 'var(--c-primary)' : 'var(--c-surface-2, var(--c-surface))';
|
||||
const textColor = isProband ? '#fff' : 'var(--c-text)';
|
||||
const borderColor = isProband ? 'var(--c-primary)' : 'var(--c-border)';
|
||||
|
||||
return `
|
||||
<div class="pedigree-node pedigree-node--gen${gen}"
|
||||
style="background:${bgColor};
|
||||
color:${textColor};
|
||||
border:1px solid ${borderColor};
|
||||
border-radius:var(--radius-md);
|
||||
padding:var(--space-2) var(--space-3);
|
||||
width:100%;
|
||||
box-sizing:border-box;">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
${gIcon} ${_esc(node.name)}
|
||||
</div>
|
||||
${node.rufname
|
||||
? `<div style="font-size:var(--text-xs);opacity:.75;white-space:nowrap;
|
||||
overflow:hidden;text-overflow:ellipsis;">${_esc(node.rufname)}</div>`
|
||||
: ''}
|
||||
${dob
|
||||
? `<div style="font-size:var(--text-xs);opacity:.65;">${dob}</div>`
|
||||
: ''}
|
||||
${node.zuchtbuchnummer
|
||||
? `<div style="font-size:var(--text-xs);opacity:.55;white-space:nowrap;
|
||||
overflow:hidden;text-overflow:ellipsis;">${_esc(node.zuchtbuchnummer)}</div>`
|
||||
: ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Gesundheitstests-Tabelle
|
||||
// ----------------------------------------------------------
|
||||
function _renderHealthTable(tests) {
|
||||
if (!tests || !tests.length) {
|
||||
return `<p class="zp-empty">Noch keine Gesundheitstests eingetragen.</p>`;
|
||||
}
|
||||
|
||||
const rows = tests.map(t => `
|
||||
<tr>
|
||||
<td class="zp-td">
|
||||
<span style="font-weight:var(--weight-medium)">${_esc(t.test_typ || 'Sonstiges')}</span>
|
||||
${t.test_name ? `<br><span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(t.test_name)}</span>` : ''}
|
||||
</td>
|
||||
<td class="zp-td">${_healthBadge(t.test_typ || '', t.ergebnis)}</td>
|
||||
<td class="zp-td zp-td-muted">${t.untersuch_am ? _fmtDate(t.untersuch_am) : '—'}</td>
|
||||
<td class="zp-td zp-td-muted">${t.labor ? _esc(t.labor) : '—'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `
|
||||
<div class="zp-table-wrap">
|
||||
<table class="zp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="zp-th">Test</th>
|
||||
<th class="zp-th">Ergebnis</th>
|
||||
<th class="zp-th">Datum</th>
|
||||
<th class="zp-th">Labor / Institut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Gentests-Tabelle
|
||||
// ----------------------------------------------------------
|
||||
function _renderGeneticTable(tests) {
|
||||
if (!tests || !tests.length) {
|
||||
return `<p class="zp-empty">Noch keine Gentests eingetragen.</p>`;
|
||||
}
|
||||
|
||||
const rows = tests.map(t => `
|
||||
<tr>
|
||||
<td class="zp-td">
|
||||
<span style="font-weight:var(--weight-medium)">${_esc(t.marker_name || '—')}</span>
|
||||
</td>
|
||||
<td class="zp-td">${_geneticBadge(t.ergebnis_klasse)}</td>
|
||||
<td class="zp-td zp-td-muted">${t.getestet_am ? _fmtDate(t.getestet_am) : '—'}</td>
|
||||
<td class="zp-td zp-td-muted">${t.labor ? _esc(t.labor) : '—'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `
|
||||
<div class="zp-table-wrap">
|
||||
<table class="zp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="zp-th">Marker / Gen</th>
|
||||
<th class="zp-th">Ergebnis</th>
|
||||
<th class="zp-th">Datum</th>
|
||||
<th class="zp-th">Labor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Titel-Liste
|
||||
// ----------------------------------------------------------
|
||||
function _renderTitlesList(titles) {
|
||||
if (!titles || !titles.length) {
|
||||
return `<p class="zp-empty">Noch keine Titel eingetragen.</p>`;
|
||||
}
|
||||
|
||||
// Chronologisch sortieren (neuestes zuerst)
|
||||
const sorted = [...titles].sort((a, b) => {
|
||||
const da = a.verliehen_am || '0000';
|
||||
const db = b.verliehen_am || '0000';
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
|
||||
const items = sorted.map(t => `
|
||||
<div class="zp-title-item">
|
||||
<div class="zp-title-badges">
|
||||
${_titleTypBadge(t.titel_typ)}
|
||||
${t.formwert
|
||||
? `<span class="zp-badge" style="background:#3B82F6">${_esc(t.formwert)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="zp-title-name">${_esc(t.titel_name || '—')}</div>
|
||||
<div class="zp-title-meta">
|
||||
${t.verliehen_am ? `${UI.icon('calendar-dots')} ${_fmtDate(t.verliehen_am)}` : ''}
|
||||
${t.ort ? ` · ${UI.icon('map-pin')} ${_esc(t.ort)}` : ''}
|
||||
${t.richter ? ` · ${UI.icon('user')} ${_esc(t.richter)}` : ''}
|
||||
${t.ausstellung ? `<br><span style="font-size:var(--text-xs)">${UI.icon('ticket')} ${_esc(t.ausstellung)}</span>` : ''}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
return `<div class="zp-titles-list">${items}</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// CSS (einmalig injizieren)
|
||||
// ----------------------------------------------------------
|
||||
(function _injectStyles() {
|
||||
if (document.getElementById('zp-styles')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'zp-styles';
|
||||
s.textContent = `
|
||||
|
||||
/* Layout */
|
||||
.zp-layout {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.zp-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.zp-header-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.zp-header-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.zp-header-name {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
margin: 0 0 var(--space-1);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.zp-header-rufname {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
.zp-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.zp-header-ident {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.zp-meta-sep {
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
.zp-header-notiz {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-secondary);
|
||||
font-style: italic;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Sektion */
|
||||
.zp-section {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.zp-section-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
margin: 0 0 var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Stammbaum-Wrapper: horizontal scrollbar auf Mobile */
|
||||
.zp-pedigree-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Stammbaum-Zellen */
|
||||
.pedigree-cell {
|
||||
box-sizing: border-box;
|
||||
padding: 2px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.pedigree-cell:not(.pedigree-empty):hover .pedigree-node {
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.pedigree-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pedigree-unknown {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border: 1px dashed var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--text-lg);
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/* Tabellen */
|
||||
.zp-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.zp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zp-th {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--c-text-secondary);
|
||||
background: var(--c-surface-2, var(--c-bg));
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.zp-td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.zp-table tbody tr:last-child .zp-td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.zp-table tbody tr:hover {
|
||||
background: var(--c-surface-2, var(--c-bg));
|
||||
}
|
||||
|
||||
.zp-td-muted {
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.zp-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Titel-Liste */
|
||||
.zp-titles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.zp-title-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-surface-2, var(--c-bg));
|
||||
}
|
||||
|
||||
.zp-title-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zp-title-name {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-sm);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.zp-title-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Leer-Zustand */
|
||||
.zp-empty {
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
1299
backend/static/js/pages/zuchthunde.js
Normal file
1299
backend/static/js/pages/zuchthunde.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v450';
|
||||
const CACHE_VERSION = 'by-v465';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue