banyaro/backend/static/js/pages/zucht-profil.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

718 lines
23 KiB
JavaScript

/* ============================================================
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 class="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 class="text-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 mb-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 text-xs-secondary">
${elternItems.join(' &nbsp;·&nbsp; ')}
</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 class="text-xs-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 ? `&nbsp;·&nbsp; ${UI.icon('map-pin')} ${_esc(t.ort)}` : ''}
${t.richter ? `&nbsp;·&nbsp; ${UI.icon('user')} ${_esc(t.richter)}` : ''}
${t.ausstellung ? `<br><span class="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 };
})();