Files
GerbilManager/gerbil-manager-web/src/pages/GerbilDetailPage.tsx
Gulum 1469a7f3dc Merge branch 'worktree-agent-a5f48334696288159'
# Conflicts:
#	gerbil-manager-web/e2e/mock-data.ts
2026-06-22 22:54:50 +02:00

514 lines
20 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 } from '../api/gerbils'
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, displayGenotypeSafe } from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
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'
// 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). */
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' }))
// 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 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)
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : 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>
<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>
)}
</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>
<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>
</>
)}
{g.status === 'GivenAway' && (
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
)}
<Kv label={t.fields.colorVariety}>
{storedColorName ? (
<>
<FarbschlagImage name={storedColorName} size={22} />
{storedColorName}
</>
) : (
'—'
)}
</Kv>
{g.spottingType && (
<Kv label={t.fields.spottingType}>
{de.pages.gerbils.spottingTypes[
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
] || g.spottingType}
</Kv>
)}
{showEnclosure && (
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
)}
<Kv label={t.fields.litter}>
{g.litterId && 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>
{g.status !== 'Deceased' && (
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</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>
</Kv>
<Kv label={t.detail.resolvedPrefix}>
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<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>
</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>
)}
</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'] 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} />}
</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>
)
}