Zwei Probleme bei der großen Rennmausliste (und allen Infinite-Scroll-Listen) nach Zurück-Navigation behoben: 1) Event- statt Timer-basiert: Die Scroll-Wiederherstellung hängt sich jetzt per ResizeObserver an die Höhenänderung der Seite (Liste ist wieder da) und springt dann zur gemerkten Position — statt auf einen festen Timer zu warten. Bricht bei eigener Scroll-Eingabe ab; Sicherheitsnetz nach 15 s. 2) Infinite-Scroll baut Höhe wieder auf: useInfiniteList merkt sich (optionaler restoreKey) die geladene Seitenzahl je Query und lädt sie nach einer Zurück- Navigation wieder nach — sonst war die Liste nur 1 Seite hoch und die Position unerreichbar. Aktiviert für Rennmäuse, Kontakte, Würfe, Becken, Anfragen, Verträge. tsc/eslint/vitest grün; e2e (Listen + Navigation, 41) grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { de } from '../strings/de'
|
||
import { listLitters } from '../api/litters'
|
||
import { listGerbils } from '../api/gerbils'
|
||
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
|
||
import type { Gerbil, Litter } from '../api/types'
|
||
import { useApi } from '../hooks/useApi'
|
||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||
import { formatDate } from '../format/labels'
|
||
import { FilterPanel } from '../components/FilterPanel'
|
||
import { useGerbilName } from '../components/breederSuffix'
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
type Tab = 'litters' | 'pairs'
|
||
type SortKey = 'dateDesc' | 'dateAsc'
|
||
// Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
|
||
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
|
||
|
||
function yearOptions(): number[] {
|
||
const current = new Date().getFullYear()
|
||
return Array.from({ length: 30 }, (_, 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() {
|
||
const t = de.pages.litters
|
||
const [tab, setTab] = useState<Tab>('litters')
|
||
const [search, setSearch] = useState('')
|
||
const [year, setYear] = useState('')
|
||
const [sort, setSort] = useState<SortKey>('dateDesc')
|
||
|
||
// Parent names (shared by both tabs) — keep the full gerbil so the Zucht suffix applies.
|
||
const gerbilName = useGerbilName()
|
||
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||
const gerbilById = useMemo(() => {
|
||
const map = new Map<string, Gerbil>()
|
||
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)
|
||
return map
|
||
}, [gerbils.data])
|
||
const parentName = useCallback((id: string | null): string => {
|
||
if (!id) return '—'
|
||
const gp = gerbilById.get(id)
|
||
return gp ? gerbilName(gp) || de.pages.gerbils.nameless : t.detail.unknownParent
|
||
}, [gerbilById, gerbilName, t.detail.unknownParent])
|
||
|
||
// Litters tab (paged, filtered).
|
||
const filter = andFilter(
|
||
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
|
||
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
|
||
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
|
||
)
|
||
const orderBy = SORT_ORDER_BY[sort]
|
||
const litters = useInfiniteList(
|
||
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
|
||
`${filter}|${orderBy}`,
|
||
'wuerfe',
|
||
)
|
||
const littersSentinelRef = useInfiniteSentinel(litters)
|
||
|
||
// 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])
|
||
|
||
// Search-enabled pairs list (client-side search by parent name)
|
||
const filteredPairs = useMemo(() => {
|
||
if (!search.trim()) return pairs
|
||
const term = search.toLowerCase()
|
||
return pairs.filter((p) =>
|
||
parentName(p.fatherId).toLowerCase().includes(term) ||
|
||
parentName(p.motherId).toLowerCase().includes(term)
|
||
)
|
||
}, [pairs, search, parentName])
|
||
|
||
const items = litters.items
|
||
|
||
return (
|
||
<section className="page">
|
||
<header className="page-head">
|
||
<h2>{t.title}</h2>
|
||
<Link to="/wuerfe/neu" className="btn btn--primary">
|
||
+ {t.newButton}
|
||
</Link>
|
||
</header>
|
||
|
||
<div className="tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tab === 'litters'}
|
||
className={tab === 'litters' ? 'tab tab--active' : 'tab'}
|
||
onClick={() => setTab('litters')}
|
||
>
|
||
{t.tabs.litters}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tab === 'pairs'}
|
||
className={tab === 'pairs' ? 'tab tab--active' : 'tab'}
|
||
onClick={() => setTab('pairs')}
|
||
>
|
||
{t.tabs.pairs}
|
||
</button>
|
||
</div>
|
||
|
||
{tab === 'litters' && (
|
||
<>
|
||
<FilterPanel
|
||
searchField={
|
||
<input
|
||
type="search"
|
||
className="input"
|
||
placeholder={t.searchPlaceholder}
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
aria-label="Wurf suchen"
|
||
/>
|
||
}
|
||
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
|
||
onReset={() => {
|
||
setSearch('')
|
||
setYear('')
|
||
setSort('dateDesc')
|
||
}}
|
||
>
|
||
<label className="field">
|
||
<span>{t.filterYear}</span>
|
||
<select
|
||
value={year}
|
||
onChange={(e) => setYear(e.target.value)}
|
||
>
|
||
<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>
|
||
</FilterPanel>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||
{litters.hasMore && (
|
||
<div ref={littersSentinelRef} aria-hidden="true" style={{ height: 1 }} />
|
||
)}
|
||
{litters.loadingMore && (
|
||
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||
{de.common.loading}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{tab === 'pairs' && (
|
||
<>
|
||
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||
<input
|
||
type="search"
|
||
className="input"
|
||
style={{ maxWidth: '320px' }}
|
||
placeholder="Elternteil suchen …"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
aria-label="Elternteil suchen"
|
||
/>
|
||
{search && (
|
||
<button
|
||
type="button"
|
||
className="btn btn--secondary btn--sm"
|
||
onClick={() => setSearch('')}
|
||
>
|
||
{de.pages.gerbils.filters.reset}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||
{!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
|
||
{filteredPairs.length > 0 && (
|
||
<ul className="card-list">
|
||
{filteredPairs.map((p) => (
|
||
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
|
||
<span className="gerbil-card__name">
|
||
{parentName(p.fatherId)} × {parentName(p.motherId)}
|
||
</span>
|
||
<span className="gerbil-card__meta">
|
||
{p.count} {t.pairs.litterCount}
|
||
</span>
|
||
<span className="gerbil-card__meta">
|
||
{t.pairs.lastLitter}: {formatDate(p.lastDate)}
|
||
</span>
|
||
<Link to={`/genetik?vater=${p.fatherId}&mutter=${p.motherId}`} className="link-btn">
|
||
{t.pairs.testMating}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|