Files
GerbilManager/gerbil-manager-web/src/pages/WuerfeListPage.tsx
Gulum 6eb82da90c UX-MOBILE-1: FilterPanel — einklappbare Filter auf Mobil (390px)
Neues FilterPanel-Muster (src/components/FilterPanel.tsx + filterPanel.css):
- Mobil (<768px): Suchfeld immer sichtbar; alle weiteren Filter hinter einem
  'Filter (N)'-Button eingeklappt (Badge = Anzahl aktiver Nicht-Default-Filter).
  Tippen öffnet/schließt den Drawer. 'Filter zurücksetzen' im Drawer.
- Desktop (>=768px): Toggle versteckt, Drawer als display:contents → alle Controls
  inline in der bestehenden .filters-Flexzeile, wie bisher.
Auf GerbilsPage, WuerfeListPage und AnfragenPage ausgerollt.
BeckenPage/KontaktePage nur ein Suchfeld → kein Panel nötig.

e2e: filter-panel.spec.ts (8 Tests, phone+desktop); bestand/tiere/anfragen-Specs
erhalten openFilterPanel-Aufruf vor Drawer-Controls; fixtures.ts +openFilterPanel().
Vitest 78 / e2e 102 grün.

Mobil-Pass (390px, Durchsicht): Hauptproblem behoben. Beobachtete Restpunkte
für god (nicht selbst gefixed):
- .genotype-table in Genetik/Probeverpaarung: Genotyp-Strings können eng werden
- Stammbaum (react-d3-tree SVG): kein explizites mobile Viewport-Handling
- BreedingResultView Genotyp-Detail-Tabelle: ggf. horizontal scrollbar-los

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:28:13 +02:00

232 lines
8.1 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 } 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, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
const PAGE_SIZE = 20
type Tab = 'litters' | 'pairs'
type SortKey = 'dateDesc' | 'dateAsc'
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc: 'date' }
function yearOptions(): number[] {
const current = new Date().getFullYear()
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() {
const t = de.pages.litters
const [tab, setTab] = useState<Tab>('litters')
const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc')
const [page, setPage] = useState(1)
// Parent names (shared by both tabs).
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const nameById = useMemo(() => {
const map = new Map<string, string>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
return map
}, [gerbils.data])
const parentName = (id: string | null) => (id ? (nameById.get(id) ?? t.detail.unknownParent) : '—')
// Litters tab (paged, filtered).
const filter = andFilter(
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 query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
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 totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = litters.data?.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
activeCount={year !== '' ? 1 : 0}
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
>
<label className="field">
<span>{t.filterYear}</span>
<select
value={year}
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>
</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>
)}
{totalPages > 1 && (
<nav className="pager" aria-label={de.common.pageNav}>
<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>
)}
</>
)}
{tab === 'pairs' && (
<>
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
{!allLitters.loading && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{pairs.length > 0 && (
<ul className="card-list">
{pairs.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>
)
}