Files
GerbilManager/gerbil-manager-web/src/pages/WurfDetailPage.tsx
Gulum 299c038449 feat(JUVENILE-AVATARS): Foto-Avatare pro Jungtier im Wurf-Detail
- JuvenileCard-Komponente in WurfDetailPage mit 36px-Kreis-Avatar links
- Fallback auf GerbilIcon (SVG-Silhouette) wenn kein profilePhotoUrl
- URL-basiertes Fehler-Tracking (failedUrl) statt useEffect+imgError
- wuerfe.css: Flex-Layout fuer Card+Avatar, overflow:hidden/object-fit:cover
- StammbaumPage: gleiche Lint-Verletzung (react-hooks/set-state-in-effect)
  behoben (failedUrl-Pattern, kein useEffect mehr fuer Bild-Reset)
- E2E: profilePhotoUrl auf Pippa (pup-abgabe), neuer Avatar-Test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:20:19 +02:00

227 lines
7.7 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 { useCallback, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { getLitter } from '../api/litters'
import { getGerbil, listGerbils } from '../api/gerbils'
import { listColorVarieties, listContacts } from '../api/lookups'
import { API_BASE_URL } from '../api/client'
import { condition } from '../api/gridify'
import { type Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import './wuerfe.css'
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
function JuvenileCard({ g, farbe, receiver, tJf }: {
g: Gerbil
farbe: string | null
receiver: string | null
tJf: JuvenileFields
}) {
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const rawUrl = g.profilePhotoUrl
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
? g.profilePhotoUrl
: `${API_BASE_URL}${g.profilePhotoUrl}`
: null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
return (
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card gerbil-card--with-photo">
<div className="juvenile-photo">
{photoUrl ? (
<img
src={photoUrl}
alt={g.name || ''}
className="juvenile-photo__avatar"
onError={() => setFailedUrl(rawUrl)}
/>
) : (
<GerbilIcon size="1.4rem" aria-hidden="true" />
)}
</div>
<div className="juvenile-body">
<span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
</div>
</Link>
)
}
export default function WurfDetailPage() {
const t = de.pages.litters
const { id = '' } = useParams()
const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null
const motherId = litter.data?.motherId ?? null
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
// Juveniles = gerbils whose litterId is this litter.
const juveniles = useApi(
() =>
id
? listGerbils({
filter: condition({ field: 'litterId', op: '==', value: id }),
orderBy: 'name',
page: 1,
pageSize: 1000,
})
: Promise.resolve(null),
[id],
)
// Expected Farbschlag distribution for this pairing (client-side GEN-1).
const expected: BreedingResult | null = useMemo(() => {
const fg = father.data?.genotype
const mg = mother.data?.genotype
if (!fg || !mg || !isValidGenotype(fg) || !isValidGenotype(mg)) return null
return breed(fromDisplayString(fg), fromDisplayString(mg))
}, [father.data, mother.data])
// Farbschlag + Abnehmer lookups for the juvenile list.
const colorVarieties = useApi(() => listColorVarieties(), [])
const contacts = useApi(() => listContacts(), [])
const colorNameById = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const contactNameById = useMemo(
() => new Map((contacts.data ?? []).map((c) => [c.id, c.name])),
[contacts.data],
)
const farbschlagOf = useCallback(
(g: { colorVarietyId: string | null; genotype: string | null }): string | null => {
if (g.colorVarietyId) {
const name = colorNameById.get(g.colorVarietyId)
if (name) return name
}
if (g.genotype?.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
return name === UNKNOWN_FARBSCHLAG ? null : name
} catch {
return null
}
}
return null
},
[colorNameById],
)
if (litter.loading) return <p className="muted">{de.common.loading}</p>
if (litter.error || !litter.data) {
return (
<section className="page">
<p className="muted">{litter.error ?? t.detail.notFound}</p>
<Link to="/wuerfe" className="btn">
{t.detail.back}
</Link>
</section>
)
}
const l = litter.data
const registered = juveniles.data?.totalCount ?? 0
const parentLink = (id: string | null, g: { name: string } | null) =>
id && g ? <Link to={`/rennmaeuse/${id}`}>{g.name}</Link> : t.detail.unknownParent
return (
<section className="page">
<header className="page-head">
<h2>{l.name}</h2>
<div className="head-actions">
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<Link to="/wuerfe" className="btn">
{t.detail.back}
</Link>
</div>
</header>
<dl className="def-list">
<div className="def-row">
<dt>{t.fields.date}</dt>
<dd>{formatDate(l.date)}</dd>
</div>
<div className="def-row">
<dt>{t.detail.parents}</dt>
<dd>
{parentLink(fatherId, father.data)} × {parentLink(motherId, mother.data)}
</dd>
</div>
<div className="def-row">
<dt>{t.detail.registered}</dt>
<dd>
{registered}
{l.totalBorn != null ? ` / ${l.totalBorn}` : ''}
</dd>
</div>
{l.deathsWithin8Weeks != null && (
<div className="def-row">
<dt>{t.fields.deathsWithin8Weeks}</dt>
<dd>{l.deathsWithin8Weeks}</dd>
</div>
)}
<div className="def-row">
<dt>{t.fields.expectedGoHomeDate}</dt>
<dd>{formatDate(l.expectedGoHomeDate)}</dd>
</div>
{l.notes && (
<div className="def-row">
<dt>{t.fields.notes}</dt>
<dd>{l.notes}</dd>
</div>
)}
</dl>
<div className="page-head">
<h3>{t.detail.juveniles}</h3>
<Link
to={`/rennmaeuse/neu?litterId=${l.id}${l.date ? `&dob=${l.date}` : ''}`}
className="btn btn--primary"
>
+ {t.detail.registerJuvenile}
</Link>
</div>
{juveniles.loading && <p className="muted">{de.common.loading}</p>}
{!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>}
{registered > 0 && (
<ul className="card-list">
{(juveniles.data?.items ?? []).map((g) => {
const farbe = farbschlagOf(g)
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) ?? null : null
const tJf = t.detail.juvenileFields
return (
<li key={g.id}>
<JuvenileCard g={g} farbe={farbe} receiver={receiver} tJf={tJf} />
</li>
)
})}
</ul>
)}
{expected ? (
<BreedingResultView result={expected} title={t.detail.expectedColors} />
) : (
<p className="muted">{t.detail.needParentsGenotype}</p>
)}
</section>
)
}