FEAT-3: Zuchtpaar-Übersicht tab (derive pairs from litters) + ?vater/&mutter pair prefill in Probeverpaarung

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:32:44 +02:00
parent 79f260d62e
commit e76554342b
2 changed files with 194 additions and 105 deletions

View File

@@ -16,20 +16,34 @@ export default function GenetikPage() {
const [father, setFather] = useState<ParentValue>(EMPTY_PARENT) const [father, setFather] = useState<ParentValue>(EMPTY_PARENT)
const [mother, setMother] = useState<ParentValue>(EMPTY_PARENT) const [mother, setMother] = useState<ParentValue>(EMPTY_PARENT)
// Pre-fill a parent when arriving from an animal's detail page (?parent=<id>). // Pre-fill parents from query params:
// Gender decides the side: female -> Mutter, otherwise Vater. // ?parent=<id> single animal from its detail page (gender picks side)
// ?vater=<id>&mutter=<id> a whole pair, e.g. from the Zuchtpaar overview
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const parentId = searchParams.get('parent') const parentId = searchParams.get('parent')
const prefill = useApi( const vaterId = searchParams.get('vater')
() => (parentId ? getGerbil(parentId) : Promise.resolve(null)), const mutterId = searchParams.get('mutter')
[parentId],
) const prefill = useApi(() => (parentId ? getGerbil(parentId) : Promise.resolve(null)), [parentId])
const [prefilledFor, setPrefilledFor] = useState<string | null>(null) const prefillVater = useApi(() => (vaterId ? getGerbil(vaterId) : Promise.resolve(null)), [vaterId])
if (prefill.data && prefilledFor !== prefill.data.id) { const prefillMutter = useApi(() => (mutterId ? getGerbil(mutterId) : Promise.resolve(null)), [mutterId])
setPrefilledFor(prefill.data.id)
const value: ParentValue = { genotype: prefill.data.genotype ?? '', animalName: prefill.data.name } const [prefilledKey, setPrefilledKey] = useState<string | null>(null)
if (prefill.data.gender === 'female') setMother(value) const prefillKey = `${parentId ?? ''}|${vaterId ?? ''}|${mutterId ?? ''}`
else setFather(value) const allReady =
(!parentId || prefill.data) && (!vaterId || prefillVater.data) && (!mutterId || prefillMutter.data)
if (prefillKey !== '||' && allReady && prefilledKey !== prefillKey) {
setPrefilledKey(prefillKey)
const toValue = (g: { genotype: string | null; name: string }): ParentValue => ({
genotype: g.genotype ?? '',
animalName: g.name,
})
if (prefillVater.data) setFather(toValue(prefillVater.data))
if (prefillMutter.data) setMother(toValue(prefillMutter.data))
if (prefill.data) {
if (prefill.data.gender === 'female') setMother(toValue(prefill.data))
else setFather(toValue(prefill.data))
}
} }
const bothValid = isValidGenotype(father.genotype) && isValidGenotype(mother.genotype) const bothValid = isValidGenotype(father.genotype) && isValidGenotype(mother.genotype)

View File

@@ -4,37 +4,62 @@ import { de } from '../strings/de'
import { listLitters } from '../api/litters' import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils' import { listGerbils } from '../api/gerbils'
import { andFilter, condition, type GridifyQuery } from '../api/gridify' import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels' import { formatDate } from '../format/labels'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
type Tab = 'litters' | 'pairs'
type SortKey = 'dateDesc' | 'dateAsc' type SortKey = 'dateDesc' | 'dateAsc'
const SORT_ORDER_BY: Record<SortKey, string> = { const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc: 'date' }
dateDesc: 'date desc',
dateAsc: 'date',
}
/** Years offered in the filter: current year back through 15 years. */
function yearOptions(): number[] { function yearOptions(): number[] {
const current = new Date().getFullYear() const current = new Date().getFullYear()
return Array.from({ length: 16 }, (_, i) => current - i) return Array.from({ length: 16 }, (_, i) => current - i)
} }
interface Pair {
fatherId: string
motherId: string
count: number
lastDate: string
}
/** Group litters (with both parents known) by (father, mother). */
function derivePairs(litters: Litter[]): Pair[] {
const map = new Map<string, Pair>()
for (const l of litters) {
if (!l.fatherId || !l.motherId) continue
const key = `${l.fatherId}|${l.motherId}`
const existing = map.get(key)
if (existing) {
existing.count += 1
if (l.date > existing.lastDate) existing.lastDate = l.date
} else {
map.set(key, { fatherId: l.fatherId, motherId: l.motherId, count: 1, lastDate: l.date })
}
}
return Array.from(map.values()).sort((a, b) => b.lastDate.localeCompare(a.lastDate))
}
export default function WuerfeListPage() { export default function WuerfeListPage() {
const t = de.pages.litters const t = de.pages.litters
const [tab, setTab] = useState<Tab>('litters')
const [year, setYear] = useState('') const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc') const [sort, setSort] = useState<SortKey>('dateDesc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Resolve parent names for the list rows. // Parent names (shared by both tabs).
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), []) const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const nameById = useMemo(() => { const nameById = useMemo(() => {
const map = new Map<string, string>() const map = new Map<string, string>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name) for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
return map return map
}, [gerbils.data]) }, [gerbils.data])
const parentName = (id: string | null) => (id ? (nameById.get(id) ?? t.detail.unknownParent) : '—')
// Litters tab (paged, filtered).
const filter = andFilter( const filter = andFilter(
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }), year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }), year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
@@ -43,109 +68,159 @@ export default function WuerfeListPage() {
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
const litters = useApi(() => listLitters(query), [filter, orderBy, page]) const litters = useApi(() => listLitters(query), [filter, orderBy, page])
// Pairs tab: all litters, grouped client-side.
const allLitters = useApi(
() => (tab === 'pairs' ? listLitters({ page: 1, pageSize: 1000, orderBy: 'date desc' }) : Promise.resolve(null)),
[tab],
)
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
const total = litters.data?.totalCount ?? 0 const total = litters.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = litters.data?.items ?? [] const items = litters.data?.items ?? []
const parentName = (id: string | null) => (id ? (nameById.get(id) ?? t.detail.unknownParent) : '—')
return ( return (
<section className="page"> <section className="page">
<header className="page-head"> <header className="page-head">
<div> <h2>{t.title}</h2>
<h2>{t.title}</h2>
{litters.data && (
<p className="muted">
{total} {t.countLabel}
</p>
)}
</div>
<Link to="/wuerfe/neu" className="btn btn--primary"> <Link to="/wuerfe/neu" className="btn btn--primary">
+ {t.newButton} + {t.newButton}
</Link> </Link>
</header> </header>
<div className="filters"> <div className="tabs" role="tablist">
<label className="field"> <button
<span>{t.filterYear}</span> type="button"
<select role="tab"
value={year} aria-selected={tab === 'litters'}
onChange={(e) => { className={tab === 'litters' ? 'tab tab--active' : 'tab'}
setYear(e.target.value) onClick={() => setTab('litters')}
setPage(1) >
}} {t.tabs.litters}
> </button>
<option value="">{t.allYears}</option> <button
{yearOptions().map((y) => ( type="button"
<option key={y} value={y}> role="tab"
{y} aria-selected={tab === 'pairs'}
</option> className={tab === 'pairs' ? 'tab tab--active' : 'tab'}
))} onClick={() => setTab('pairs')}
</select> >
</label> {t.tabs.pairs}
<label className="field"> </button>
<span>{de.pages.gerbils.filters.sortBy}</span>
<select value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
<option value="dateDesc">{t.sort.dateDesc}</option>
<option value="dateAsc">{t.sort.dateAsc}</option>
</select>
</label>
</div> </div>
{litters.loading && <p className="muted">{de.common.loading}</p>} {tab === 'litters' && (
{litters.error && ( <>
<div className="alert alert--error"> <div className="filters">
<span>{litters.error}</span> <label className="field">
<button type="button" className="btn" onClick={litters.reload}> <span>{t.filterYear}</span>
{de.common.retry} <select
</button> value={year}
</div> onChange={(e) => {
setYear(e.target.value)
setPage(1)
}}
>
<option value="">{t.allYears}</option>
{yearOptions().map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</label>
<label className="field">
<span>{de.pages.gerbils.filters.sortBy}</span>
<select value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
<option value="dateDesc">{t.sort.dateDesc}</option>
<option value="dateAsc">{t.sort.dateAsc}</option>
</select>
</label>
</div>
{litters.loading && <p className="muted">{de.common.loading}</p>}
{litters.error && (
<div className="alert alert--error">
<span>{litters.error}</span>
<button type="button" className="btn" onClick={litters.reload}>
{de.common.retry}
</button>
</div>
)}
{!litters.loading && !litters.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{items.length > 0 && (
<ul className="card-list">
{items.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="gerbil-card">
<span className="gerbil-card__name">{l.name}</span>
<span className="gerbil-card__meta">{formatDate(l.date)}</span>
<span className="gerbil-card__meta">
{parentName(l.fatherId)} × {parentName(l.motherId)}
</span>
<span className="gerbil-card__meta">
{l.totalBorn != null ? `${l.totalBorn} ${t.fields.totalBorn}` : '—'}
</span>
</Link>
</li>
))}
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
)}
</>
)} )}
{!litters.loading && !litters.error && items.length === 0 && ( {tab === 'pairs' && (
<p className="muted">{t.empty}</p> <>
)} {allLitters.loading && <p className="muted">{de.common.loading}</p>}
{!allLitters.loading && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{items.length > 0 && ( {pairs.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{items.map((l) => ( {pairs.map((p) => (
<li key={l.id}> <li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
<Link to={`/wuerfe/${l.id}`} className="gerbil-card"> <span className="gerbil-card__name">
<span className="gerbil-card__name">{l.name}</span> {parentName(p.fatherId)} × {parentName(p.motherId)}
<span className="gerbil-card__meta">{formatDate(l.date)}</span> </span>
<span className="gerbil-card__meta"> <span className="gerbil-card__meta">
{parentName(l.fatherId)} × {parentName(l.motherId)} {p.count} {t.pairs.litterCount}
</span> </span>
<span className="gerbil-card__meta"> <span className="gerbil-card__meta">
{l.totalBorn != null ? `${l.totalBorn} ${t.fields.totalBorn}` : '—'} {t.pairs.lastLitter}: {formatDate(p.lastDate)}
</span> </span>
</Link> <Link to={`/genetik?vater=${p.fatherId}&mutter=${p.motherId}`} className="link-btn">
</li> {t.pairs.testMating}
))} </Link>
</ul> </li>
)} ))}
</ul>
{totalPages > 1 && ( )}
<nav className="pager" aria-label="Seitennavigation"> </>
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
)} )}
</section> </section>
) )