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 = { Breeding: '#5e8c5a', Pet: '#5a7da8', ForSale: '#c2703d', Deceased: '#8a847b', GivenAway: '#b08e5c', } const GENDER_SYMBOL: Record = { 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 (
{label}
{children}
) } export default function GerbilDetailPage() { const t = de.pages.gerbils const gerbilName = useGerbilName() const toast = useToast() const { id = '' } = useParams() const [tab, setTab] = useState('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([]) const [charNote, setCharNote] = useState('') const [charInit, setCharInit] = useState(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() 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() 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

{de.common.loading}

if (gerbil.error || !gerbil.data) { return (

{gerbil.error ?? t.detail.notFound}

{t.detail.back}
) } 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, 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[0] | null, ): ReactNode => pid && p ? ( {gerbilName(p) || de.pages.gerbils.nameless} ) : ( 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 (
} />

{gerbilName(g) || de.pages.gerbils.nameless}

{g.isResident !== false && (parentLitters.data?.items.length ?? 0) > 0 && ( {t.detail.zuchttierBadge} )} {statusLabel(g.status)} {g.isResident === false && ( {de.pages.gerbils.externalBadge} )} {verifiedStatus.data?.isVerified && ( {de.verified.verifiedBadge} )} {verifiedStatus.data && !verifiedStatus.data.isVerified && ( {de.verified.protectedBadge} )} {verifiedStatus.data?.status === 'drifted' && ( {de.verified.driftBadge} )}
{genderLabel(g.gender)} {g.isCastrated ? ' (Kastrat)' : ''} {g.dateOfBirth && ( {formatDate(g.dateOfBirth)} )} {storedColorName && ( {storedColorName} )} {isSiblingPairing && ( {t.detail.siblingPairing} )}
{t.detail.edit} {t.detail.testMating} {de.pages.stammbaum.openButton} {g.status !== 'ForSale' && g.status !== 'Deceased' && ( )} {g.status !== 'Deceased' && g.status !== 'GivenAway' && ( {de.pages.vertraege.wizard.title} )} {t.detail.back}
{/* 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 (

{vt.sectionTitle}

{isProtected ? vt.explainProtected : vt.explainIntro}

{!isVerified && ( )} {isVerified && ( )} {isProtected && ( )}
{isVerified && !drifted &&

{vt.statusUnchanged}

} {isVerified && drifted && vs!.importDiff.length > 0 && ( <>

{vt.driftHeading}

{vt.driftHint}

{vs!.importDiff.map((d) => ( {d.goldenValue || '—'} → {vt.colImport}: {d.importValue || '—'} ))}
)}
) })()}

{t.detail.masterData}

{genderLabel(g.gender)} {g.isCastrated && (Kastrat)} {statusLabel(g.status)} {formatDate(g.dateOfBirth)} {g.status === 'Deceased' && ( <> {formatDate(g.dateOfDeath)} {g.causeOfDeath || '—'} )} {storedColorName ? ( <> {storedColorName} ) : ( '—' )} {/* 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 ( {g.spottingType ? de.pages.gerbils.spottingTypes[ g.spottingType as keyof typeof de.pages.gerbils.spottingTypes ] || g.spottingType : de.pages.gerbils.spottingTypes.notRecorded} ); })()} {showEnclosure && ( {lookup(enclosureName, g.enclosureId)} )} {/* 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 && ( {litterName.has(g.litterId) ? ( {litterName.get(g.litterId)} ) : ( lookup(litterName, g.litterId) )} )} {ownLitter && ( <> {parentLink(fatherId, father.data)} {parentLink(motherId, mother.data)} )} {g.originContactId && contactName.has(g.originContactId) ? ( {contactName.get(g.originContactId)} ) : ( g.originBreeder || '—' )} {/* 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') && ( <> {lookup(contactName, g.receiverContactId)} {formatDate(g.goHomeDate)} )} {g.isResident !== false && (partners.data?.items.length ?? 0) > 0 && ( {/* 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) => ( {p.name || de.pages.gerbils.nameless} {i < all.length - 1 && ','} ))} )} {g.notes || '—'}

{t.detail.genetics}

{geno ? (
{geno.display} {genoInferred && ( {' → '} {genoInferred.display}{' '} ⮑ {t.detail.genotypeInferred} )} {(() => { 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) && ( ⚠ {t.detail.farbschlagMismatch} )} ) })()}
) : (

{t.detail.genotypeNotSet}

)}
{/* 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 && (

{t.detail.nonResidentNote}

{(offspring.data?.items.length ?? 0) > 0 && (

{t.detail.nonResidentOffspringLabel}{' '} {(offspring.data?.items ?? []).map((child, i, all) => ( {child.name || de.pages.gerbils.nameless} {i < all.length - 1 && ','} ))}

)}
)} {g.isResident !== false && (
{charExpanded && ( <> {TRAIT_CATEGORIES.map((cat) => { const count = cat.traits.filter((tr) => selectedTraits.has(tr.key)).length return (
{cat.category} {count > 0 && {count}}
{cat.traits.map((tr) => { const on = selectedTraits.has(tr.key) return ( ) })}
) })}