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>
835 lines
34 KiB
TypeScript
835 lines
34 KiB
TypeScript
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>
|
||
)
|
||
}
|