Files
GerbilManager/gerbil-manager-web/src/pages/GerbilDetailPage.tsx
Gulum 45c3f87f01
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m38s
CI / Docker Build & Push (push) Successful in 1m32s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 9s
fix(akte): falscher Hinweis „Weicht vom eingetragenen Farbschlag ab"
Beim Prod-Nachtest aufgefallen: Bei Schecken meldete die Akte eine Abweichung,
obwohl eingetragener und errechneter Farbschlag identisch angezeigt wurden
(JackJack: „Saphir Schecke" vs. „Saphir Schecke"). Zwei Ursachen:

- Der Vergleich schnitt „Schecke"/„Rex" nur auf der ERRECHNETEN Seite ab, nicht
  auf der eingetragenen (die den Zusatz über formatVarietyName trägt). Jetzt
  werden beide Seiten gleich normalisiert.
- formatVarietyName hängte den Modifier auch an, wenn der Katalogname ihn schon
  trägt („Schwarz Schecke" → „Schwarz Schecke Schecke").

Tests: 3 vitest-Fälle zu formatVarietyName, e2e SCHECKE-MISMATCH mit Gegenprobe
(Mock-Tier mit echter Abweichung zeigt den Hinweis weiter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:22:59 +02:00

835 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, type ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { getGerbil, updateGerbil, listGerbils } from '../api/gerbils'
import { getVerifiedStatus, verifyGerbil, unverifyGerbil } from '../api/verified'
import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import {
fromDisplayString,
genotypeToFarbschlag,
formatVarietyName,
displayGenotypeSafe,
toDisplayString,
hasUnknown,
inferUnknownsFromParents,
} from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
import GerbilExhibitionsTab from '../components/GerbilExhibitionsTab'
import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
import GerbilWeightTab from '../components/GerbilWeightTab'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast'
import './rennmausakte.css'
type DetailTab = 'photos' | 'health' | 'weight' | 'exhibitions'
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
const STATUS_COLORS: Record<GerbilStatus, string> = {
Breeding: '#5e8c5a',
Pet: '#5a7da8',
ForSale: '#c2703d',
Deceased: '#8a847b',
GivenAway: '#b08e5c',
}
const GENDER_SYMBOL: Record<Gender, string> = { unknown: '?', male: '♂', female: '♀' }
/** Parse a stored genotype string and derive its normalized form + Farbschlag. */
function describeGenotype(genotype: string | null): { display: string; farbschlag: string } | null {
if (!genotype || !genotype.trim()) return null
try {
const g = fromDisplayString(genotype)
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
} catch {
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
}
}
/** A labelled key/value row inside a card (semantic dt/dd, styled as a grid). */
/** „Saphir Schecke" → „Saphir": Modifier-Zusaetze fuer den Namensvergleich abschneiden. */
function stripVarietyModifiers(name: string): string {
return name.replace(' Schecke', '').replace(' Rex', '').trim()
}
function Kv({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="ak-kv">
<dt className="ak-kv-label">{label}</dt>
<dd className="ak-kv-value">{children}</dd>
</div>
)
}
export default function GerbilDetailPage() {
const t = de.pages.gerbils
const gerbilName = useGerbilName()
const toast = useToast()
const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos')
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierung / Schutz-Status dieses Tiers.
const verifiedStatus = useApi(() => getVerifiedStatus(id), [id])
const verify = useMutation(() => verifyGerbil(id))
const unverify = useMutation(() => unverifyGerbil(id))
// FEAT-14: Charakterbogen (persisted on the Gerbil).
const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('')
const [charInit, setCharInit] = useState<string | null>(null)
// Charakter ist v. a. für lebende Bestandstiere relevant → bei Zucht/Liebhaber
// aufgeklappt, sonst (Abgabe/Verstorben/Abgegeben) eingeklappt (manuell öffenbar).
const [charExpanded, setCharExpanded] = useState(true)
const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
)
const colorVarieties = useApi(() => listColorVarieties(), [])
const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), [])
const parentLitters = useApi(
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const breedingParentIds = useMemo(() => {
const ids = new Set<string>()
for (const l of litters.data ?? []) {
if (l.fatherId) ids.add(l.fatherId)
if (l.motherId) ids.add(l.motherId)
}
return ids
}, [litters.data])
const offspring = useApi(
() => {
const litterIds = (parentLitters.data?.items ?? []).map((l) => l.id)
if (litterIds.length === 0) {
return Promise.resolve({ items: [], totalCount: 0, page: 1, pageSize: 100 })
}
const filter = litterIds.map((lid) => `litterId=${lid}`).join('|')
return listGerbils({ filter, pageSize: 200 })
},
[parentLitters.data],
)
// ZUCHTPARTNER: die jeweils anderen Elterntiere der eigenen Würfe (distinct).
const partnerIds = useMemo(() => {
const ids = new Set<string>()
for (const l of parentLitters.data?.items ?? []) {
const pid = l.fatherId === id ? l.motherId : l.fatherId
if (pid && pid !== id) ids.add(pid)
}
return [...ids]
}, [parentLitters.data, id])
const partners = useApi(
() =>
partnerIds.length === 0
? Promise.resolve({ items: [], totalCount: 0, page: 1, pageSize: 100 })
: listGerbils({ filter: partnerIds.map((pid) => `id=${pid}`).join('|'), pageSize: 100 }),
[partnerIds],
)
const breedingOffspring = useMemo(() => {
return (offspring.data?.items ?? []).filter(
(child) => child.status === 'Breeding' || breedingParentIds.has(child.id),
)
}, [offspring.data, breedingParentIds])
const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const enclosureName = useMemo(
() => new Map((enclosures.data ?? []).map((e) => [e.id, e.name])),
[enclosures.data],
)
const contactName = useMemo(
() => new Map((contacts.data ?? []).map((c) => [c.id, c.name])),
[contacts.data],
)
const litterName = useMemo(
() => new Map((litters.data ?? []).map((l) => [l.id, l.name])),
[litters.data],
)
// Parents come from the gerbil's own litter (litterId → litter.fatherId/motherId).
// Fetch each parent gerbil for its name + link (mirrors WurfDetailPage).
const ownLitterId = gerbil.data?.litterId ?? null
const ownLitter = useMemo(
() => (ownLitterId ? ((litters.data ?? []).find((l) => l.id === ownLitterId) ?? null) : null),
[litters.data, ownLitterId],
)
const fatherId = ownLitter?.fatherId ?? null
const motherId = ownLitter?.motherId ?? null
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
// Character chips — toggle while preserving the catalog order (stable output).
const selectedTraits = new Set(charTraits)
const toggleTrait = (key: string) => {
const next = new Set(selectedTraits)
if (next.has(key)) next.delete(key)
else next.add(key)
setCharTraits(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
}
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) {
return (
<section className="page">
<p className="muted">{gerbil.error ?? t.detail.notFound}</p>
<Link to="/rennmaeuse" className="btn">
{t.detail.back}
</Link>
</section>
)
}
const g = gerbil.data
// Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege.
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
const geno = describeGenotype(g.genotype)
// GEN-5 (Tickets cc9ea3fe / 1a508c04): unbekannte Gencode-Buchstaben aus einem
// reinerbigen Elternteil ergänzen (Mendel: ein reinerbiger Elternteil kann nur
// dieses eine Allel vererben). Greift nur, wenn der Genotyp ein '-' enthält UND
// mindestens ein Elternteil mit Genotyp am eigenen Wurf hinterlegt ist.
const genoInferred = (() => {
if (!g.genotype) return null
let child
try {
child = fromDisplayString(g.genotype)
} catch {
return null
}
if (!hasUnknown(child)) return null
const parse = (s: string | null | undefined) => {
if (!s) return null
try {
return fromDisplayString(s)
} catch {
return null
}
}
const f = parse(father.data?.genotype)
const m = parse(mother.data?.genotype)
if (!f && !m) return null
const res = inferUnknownsFromParents(child, f, m)
if (res.inferred.length === 0) return null
const display = toDisplayString(res.genotype)
if (display === geno?.display) return null
return { display, farbschlag: genotypeToFarbschlag(res.genotype) }
})()
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const baseColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
const storedColorName = baseColorName ? formatVarietyName(baseColorName, g.genotype) : null
// Geschwisterverpaarung: Vater und Mutter dieses Tiers sind Vollgeschwister —
// erkennbar an gemeinsamer litterId ODER gleichem Geburtsdatum (starkes Indiz:
// ein identisches Datum entsteht praktisch nur durch einen gemeinsamen Wurf,
// und die Eltern-Würfe sind in den Daten nicht immer verknüpft).
const isSiblingPairing =
!!father.data &&
!!mother.data &&
father.data.id !== mother.data.id &&
((!!father.data.litterId && father.data.litterId === mother.data.litterId) ||
(!!father.data.dateOfBirth && father.data.dateOfBirth === mother.data.dateOfBirth))
const parentLink = (
pid: string | null,
p: Parameters<typeof gerbilName>[0] | null,
): ReactNode =>
pid && p ? (
<Link to={`/rennmaeuse/${pid}`}>{gerbilName(p) || de.pages.gerbils.nameless}</Link>
) : (
de.pages.litters.detail.unknownParent
)
// Seed the character state once from the loaded gerbil (adjust-state-during-render).
if (charInit !== g.id) {
setCharInit(g.id)
setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '')
setCharExpanded(g.status === 'Breeding' || g.status === 'Pet')
}
return (
<section className="ak">
<div className="ak-hero">
<GerbilProfilePhoto
gerbilId={g.id}
className="ak-herophoto"
fallback={
<div className="ak-herophoto ak-herophoto--empty" aria-hidden="true">
🐭
</div>
}
/>
<div className="ak-herobar">
<Link to="/rennmaeuse" className="ak-back" aria-label={t.detail.back}>
</Link>
</div>
<div className="ak-heroover">
<h1 className="ak-name">{gerbilName(g) || de.pages.gerbils.nameless}</h1>
{g.isResident !== false && (parentLitters.data?.items.length ?? 0) > 0 && (
<span
className="ak-badge-extern"
style={{ color: '#5e8c5a' }}
title={t.detail.zuchttierBadgeTitle}
>
{t.detail.zuchttierBadge}
</span>
)}
<span className="ak-statusbadge" style={{ color: STATUS_COLORS[g.status] }}>
{statusLabel(g.status)}
</span>
{g.isResident === false && (
<span className="ak-badge-extern" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
{verifiedStatus.data?.isVerified && (
<span
className="ak-badge-extern"
style={{ color: '#5e8c5a' }}
title={de.verified.verifiedBadgeTitle}
>
{de.verified.verifiedBadge}
</span>
)}
{verifiedStatus.data && !verifiedStatus.data.isVerified && (
<span
className="ak-badge-extern"
style={{ color: '#5a7da8' }}
title={de.verified.protectedBadgeTitle}
>
{de.verified.protectedBadge}
</span>
)}
{verifiedStatus.data?.status === 'drifted' && (
<span
className="ak-badge-extern"
style={{ color: '#c2703d' }}
title={de.verified.driftBadgeTitle}
>
{de.verified.driftBadge}
</span>
)}
</div>
</div>
<div className="ak-content">
<div className="ak-facts">
<span className="ak-fact">
<span aria-hidden="true">{GENDER_SYMBOL[g.gender]}</span>
{genderLabel(g.gender)}
{g.isCastrated ? ' (Kastrat)' : ''}
</span>
{g.dateOfBirth && (
<span className="ak-fact">
<span aria-hidden="true">🎂</span>
{formatDate(g.dateOfBirth)}
</span>
)}
{storedColorName && (
<span className="ak-fact">
<span aria-hidden="true">🎨</span>
{storedColorName}
</span>
)}
{isSiblingPairing && (
<span className="ak-fact ak-fact--sibling" title={t.detail.siblingPairingTitle}>
<span aria-hidden="true"></span>
{t.detail.siblingPairing}
</span>
)}
</div>
<div className="ak-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="ak-btn primary">
{t.detail.edit}
</Link>
<Link to={`/genetik?parent=${g.id}`} className="ak-btn">
{t.detail.testMating}
</Link>
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="ak-btn">
{de.pages.stammbaum.openButton}
</Link>
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
<button
type="button"
className="ak-btn"
disabled={forSale.pending}
onClick={async () => {
const r = await forSale.run()
if (r.ok) {
toast.success(de.common.saved)
gerbil.reload()
} else {
toast.error(r.error)
}
}}
>
{de.pages.abgabe.markAction}
</button>
)}
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
<Link to={`/vertraege/neu?tiere=${g.id}`} className="ak-btn">
{de.pages.vertraege.wizard.title}
</Link>
)}
<button type="button" className="ak-btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/rennmaeuse" className="ak-btn">
{t.detail.back}
</Link>
</div>
{/* GEPRÜFTE TIERE: Prüfstatus & Schutz — laienverständlich erklärt. */}
{(() => {
const vt = de.verified
const vs = verifiedStatus.data
const isVerified = vs?.isVerified === true
const isProtected = vs != null && vs.isVerified === false
const drifted = vs?.status === 'drifted'
const fieldLabel = (path: string) => vt.fieldLabels[path] ?? path
const runVerify = async () => {
const r = await verify.run()
if (r.ok) { toast.success(vt.markSuccess); verifiedStatus.reload(); gerbil.reload() }
else toast.error(r.error)
}
const runUnverify = async (confirmMsg: string) => {
if (!window.confirm(confirmMsg)) return
const r = await unverify.run()
if (r.ok) { toast.success(vt.unmarkSuccess); verifiedStatus.reload(); gerbil.reload() }
else toast.error(r.error)
}
return (
<section className="ak-card">
<h2 className="ak-h2">{vt.sectionTitle}</h2>
<p className="ak-empty">{isProtected ? vt.explainProtected : vt.explainIntro}</p>
<div className="ak-saverow">
{!isVerified && (
<button
type="button"
className="ak-btn primary"
disabled={verify.pending}
onClick={runVerify}
>
{verify.pending ? vt.marking : vt.markAction}
</button>
)}
{isVerified && (
<button
type="button"
className="ak-btn"
disabled={unverify.pending}
onClick={() => runUnverify(vt.unmarkConfirm)}
>
{vt.unmarkAction}
</button>
)}
{isProtected && (
<button
type="button"
className="ak-btn"
disabled={unverify.pending}
onClick={() => runUnverify(vt.releaseConfirm)}
>
{vt.releaseAction}
</button>
)}
</div>
{isVerified && !drifted && <p className="ak-empty">{vt.statusUnchanged}</p>}
{isVerified && drifted && vs!.importDiff.length > 0 && (
<>
<h3 className="ak-h2" style={{ fontSize: '1rem', marginTop: '0.8rem' }}>
{vt.driftHeading}
</h3>
<p className="ak-empty">{vt.driftHint}</p>
<dl className="ak-kvlist">
{vs!.importDiff.map((d) => (
<Kv key={d.path} label={fieldLabel(d.path)}>
<span>{d.goldenValue || '—'}</span>
<span className="muted"> {vt.colImport}: {d.importValue || '—'}</span>
</Kv>
))}
</dl>
</>
)}
</section>
)
})()}
<section className="ak-card">
<h2 className="ak-h2">{t.detail.masterData}</h2>
<dl className="ak-kvlist">
<Kv label={t.fields.gender}>
{genderLabel(g.gender)}
{g.isCastrated && <span style={{ marginLeft: '0.4rem', opacity: 0.8 }}>(Kastrat)</span>}
</Kv>
<Kv label={t.fields.status}>{statusLabel(g.status)}</Kv>
<Kv label={t.fields.dateOfBirth}>{formatDate(g.dateOfBirth)}</Kv>
{g.status === 'Deceased' && (
<>
<Kv label={t.fields.dateOfDeath}>{formatDate(g.dateOfDeath)}</Kv>
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
</>
)}
<Kv label={t.fields.colorVariety}>
{storedColorName ? (
<>
<FarbschlagImage name={storedColorName} size={22} />
{storedColorName}
</>
) : (
'—'
)}
</Kv>
{/* Ticket 9f92c834 (CoCo): Scheckungsart bei jeder Schecke zeigen — auch wenn noch
nicht erfasst (dann Platzhalter), damit sichtbar ist, dass es eine Schecke ist und
welche Art. Schecke erkannt am Sp-Allel im Genotyp oder am Farbschlag-Namen. */}
{(() => {
const isSchecke =
/Sp/.test(g.genotype ?? '') ||
(storedColorName?.toLowerCase().includes('schecke') ?? false);
if (!g.spottingType && !isSchecke) return null;
return (
<Kv label={t.fields.spottingType}>
{g.spottingType
? de.pages.gerbils.spottingTypes[
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
] || g.spottingType
: de.pages.gerbils.spottingTypes.notRecorded}
</Kv>
);
})()}
{showEnclosure && (
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
)}
{/* Ticket 7dedec07: Die „Wurf"-Zeile nur zeigen, wenn das Tier einen eigenen
Geburtswurf in dieser Zucht hat. Zugekaufte/externe Zuchttiere (z. B. Blacky,
litterId=null) sind nicht hier geboren → keine Wurf-Zeile. */}
{g.litterId && (
<Kv label={t.fields.litter}>
{litterName.has(g.litterId) ? (
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
) : (
lookup(litterName, g.litterId)
)}
</Kv>
)}
{ownLitter && (
<>
<Kv label={t.detail.parentLittersRoleVater}>{parentLink(fatherId, father.data)}</Kv>
<Kv label={t.detail.parentLittersRoleMutter}>{parentLink(motherId, mother.data)}</Kv>
</>
)}
<Kv label={t.fields.origin}>
{g.originContactId && contactName.has(g.originContactId) ? (
<Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
) : (
g.originBreeder || '—'
)}
</Kv>
{/* Ticket 24522f5f (JackJack): Das Abgabedatum gehört direkt unter den Abnehmer und
darf NICHT am Status hängen — Tiere, die abgegeben wurden und später beim
Abnehmer starben (status=Deceased), haben Abnehmer UND Abgabedatum. */}
{(g.receiverContactId || g.goHomeDate || g.status !== 'Deceased') && (
<>
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
</>
)}
{g.isResident !== false && (partners.data?.items.length ?? 0) > 0 && (
<Kv label={t.detail.zuchtpartner}>
{/* Trennkomma HINTEN an den Namen, nicht vorne an den nächsten: .ak-kv-value
ist ein Flex-Container mit wrap, jeder Eintrag also ein eigenes Flex-Item.
Vorangestellt landete das Komma beim Umbruch am ZEILENANFANG („, Zora"). */}
{(partners.data?.items ?? []).map((p, i, all) => (
<span key={p.id}>
<Link to={`/rennmaeuse/${p.id}`}>{p.name || de.pages.gerbils.nameless}</Link>
{i < all.length - 1 && ','}
</span>
))}
</Kv>
)}
<Kv label={t.fields.notes}>{g.notes || '—'}</Kv>
</dl>
</section>
<GerbilAcquisitionSection gerbilId={g.id} />
<section className="ak-card">
<h2 className="ak-h2">{t.detail.genetics}</h2>
{geno ? (
<dl className="ak-kvlist">
<Kv label={t.fields.genotype}>
<code className="ak-genotype">{geno.display}</code>
{genoInferred && (
<span className="ak-inferred" title={t.detail.genotypeInferredTitle}>
{' → '}
<code className="ak-genotype">{genoInferred.display}</code>{' '}
<small className="ak-inferred-chip"> {t.detail.genotypeInferred}</small>
</span>
)}
</Kv>
<Kv label={t.detail.resolvedPrefix}>
{(() => {
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
return (
<>
{resolvedFarbschlag}
{/* Vergleich OHNE die Modifier-Zusaetze auf BEIDEN Seiten: der
eingetragene Name traegt „Schecke"/„Rex" bereits (formatVarietyName
haengt sie aus dem Gencode an), der errechnete ebenfalls. Vorher wurde
der Zusatz nur rechts entfernt → jede Schecke bekam faelschlich den
Hinweis „Weicht vom eingetragenen Farbschlag ab" (z. B. JackJack:
„Saphir Schecke" vs. „Saphir Schecke"). */}
{storedColorName &&
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
stripVarietyModifiers(storedColorName) !==
stripVarietyModifiers(resolvedFarbschlag) && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
)
})()}
</Kv>
</dl>
) : (
<p className="ak-empty">{t.detail.genotypeNotSet}</p>
)}
</section>
{/* Externe Vorfahren waren nie im eigenen Bestand → Würfe & Charakter
sind für sie nicht relevant; statt der Abschnitte ein kurzer Hinweis. */}
{g.isResident === false && (
<section className="ak-card">
<p className="ak-empty">{t.detail.nonResidentNote}</p>
{(offspring.data?.items.length ?? 0) > 0 && (
<p className="ak-empty">
{t.detail.nonResidentOffspringLabel}{' '}
{(offspring.data?.items ?? []).map((child, i, all) => (
<span key={child.id}>
<Link to={`/rennmaeuse/${child.id}`}>
{child.name || de.pages.gerbils.nameless}
</Link>
{i < all.length - 1 && ','}
</span>
))}
</p>
)}
</section>
)}
{g.isResident !== false && (
<section className="ak-card">
<button
type="button"
className="ak-card-toggle"
aria-expanded={charExpanded}
onClick={() => setCharExpanded((v) => !v)}
>
<h2 className="ak-h2">{de.character.sectionTitle}</h2>
<span className="ak-card-chev" aria-hidden="true">
{charExpanded ? '▴' : '▾'}
</span>
</button>
{charExpanded && (
<>
{TRAIT_CATEGORIES.map((cat) => {
const count = cat.traits.filter((tr) => selectedTraits.has(tr.key)).length
return (
<div className="ak-cgroup" key={cat.category}>
<div className="ak-glabel">
{cat.category}
{count > 0 && <span className="ak-gcount">{count}</span>}
</div>
<div className="ak-chips">
{cat.traits.map((tr) => {
const on = selectedTraits.has(tr.key)
return (
<button
key={tr.key}
type="button"
className={'ak-chip' + (on ? ' on' : '') + (tr.warn ? ' warn' : '')}
aria-pressed={on}
onClick={() => toggleTrait(tr.key)}
>
{tr.warn && <span aria-hidden="true"></span>}
{tr.label}
</button>
)
})}
</div>
</div>
)
})}
<textarea
className="ak-notes"
value={charNote}
placeholder={de.character.notePlaceholder}
onChange={(e) => setCharNote(e.target.value)}
/>
<div className="ak-saverow">
<button
type="button"
className="ak-btn primary"
disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) {
toast.success(de.common.saved)
gerbil.reload()
} else {
toast.error(r.error)
}
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
</>
)}
</section>
)}
{g.isResident !== false && (
<section className="ak-card">
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
<p className="ak-empty">{t.detail.parentLittersEmpty}</p>
)}
{(parentLitters.data?.items.length ?? 0) > 0 && (
<ul className="card-list">
{(parentLitters.data?.items ?? []).map((litter) => (
<li key={litter.id}>
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
<span className="gerbil-card__name">{litter.name}</span>
<span className="badge badge--active">
{litter.fatherId === id
? t.detail.parentLittersRoleVater
: t.detail.parentLittersRoleMutter}
</span>
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
{litter.totalBorn !== null && (
<span className="gerbil-card__meta">
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
{litter.showInChronicle === false && (
<span className="gerbil-card__meta muted">
{t.detail.parentLittersNotInChronicle}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</section>
)}
{g.isResident !== false && (parentLitters.data?.items.length ?? 0) > 0 && (
<section className="ak-card">
<h2 className="ak-h2">{t.detail.offspringInBreedingTitle}</h2>
{offspring.loading && <p className="muted">{de.common.loading}</p>}
{!offspring.loading && (breedingOffspring.length === 0) && (
<p className="ak-empty">{t.detail.offspringInBreedingEmpty}</p>
)}
{breedingOffspring.length > 0 && (
<ul className="card-list">
{breedingOffspring.map((child) => {
const childLitter = (parentLitters.data?.items ?? []).find(l => l.id === child.litterId);
const suffix = child.originBreeder ? ` (${child.originBreeder})` : '';
return (
<li key={child.id}>
<Link to={`/rennmaeuse/${child.id}`} className="gerbil-card">
<span className="gerbil-card__name">{child.name || de.pages.gerbils.nameless}</span>
<span className="badge badge--active">
{statusLabel(child.status)}
</span>
{childLitter && (
<span className="gerbil-card__meta">
{childLitter.name}
</span>
)}
<span className="gerbil-card__meta muted">
{child.gender === 'male' ? 'Bock' : child.gender === 'female' ? 'Weibchen' : 'Unbekannt'}{suffix}
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
)}
<section className="ak-card">
<h2 className="visually-hidden">{t.detail.moreData}</h2>
<div className="ak-tabs" role="tablist">
{(['photos', 'health', 'weight', 'exhibitions'] as const).map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={tab === key ? 'ak-tab active' : 'ak-tab'}
onClick={() => setTab(key)}
>
{t.detail.tabs[key]}
</button>
))}
</div>
<div className="ak-tabpanel" role="tabpanel">
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
{tab === 'exhibitions' && (
<GerbilExhibitionsTab
gerbilId={g.id}
entityName={gerbilName(g) || de.pages.gerbils.nameless}
/>
)}
</div>
</section>
</div>
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={g.provenance}
/>
</section>
)
}