Files
GerbilManager/gerbil-manager-web/src/pages/GerbilsPage.tsx
Gulum afd321ec9f BESTAND-FILTER: Tiere-Liste zeigt standardmäßig nur den eigenen Bestand
Die Tiere-Liste (/rennmaeuse) filtert serverseitig auf isResident==true
(eigener Clan = Bestand) und blendet externe Ahnen (nur für den Stammbaum
erfasste Fremdtiere) standardmäßig aus — passend zu Julians Modell
(Kleine Chaoten = Bestand, extern = stammbaum-only).

- Neuer Schalter „Externe Ahnen einblenden“ (Default aus). An -> isResident-
  Filter entfällt, externe Tiere erscheinen mit. Serverseitig (Gridify), damit
  Paging und Trefferzahl korrekt bleiben. „Filter zurücksetzen“ setzt ihn zurück.
- Subtile „Extern“-Markierung (gestricheltes Badge) auf externen Tieren in
  Liste UND Detailseite, mit Tooltip-Erläuterung.
- Gerbil-Typ um isResident (optional bool) ergänzt; CreateGerbil ebenso.

Deutsch via de.ts (LF), mobil + Laptop. e2e-Mock: gerbil()-Factory setzt
isResident=true; Hilde & Max als externe Ahnen (isResident=false). Neue
e2e bestand.spec (Default blendet aus; Toggle zeigt + Extern-Markierung).
Gate grün: build, eslint, vitest 78, e2e 84.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:59:19 +02:00

299 lines
10 KiB
TypeScript

import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils, listOriginBreeders, updateGerbil } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import './gerbils.css'
const PAGE_SIZE = 20
// SEARCH-2b: separator-insensitive search via Pam's normalized field. The term
// is normalized the SAME way server-side stores it (lowercase + strip [\s._-]),
// so 'clan kleine chaoten' matches 'clan-kleine-chaoten'.
const NAME_SEARCH_FIELD = 'nameSearch'
const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '')
type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc'
const SORT_ORDER_BY: Record<SortKey, string> = {
nameAsc: 'name',
nameDesc: 'name desc',
birthDesc: 'dateOfBirth desc',
birthAsc: 'dateOfBirth',
}
export default function GerbilsPage() {
const t = de.pages.gerbils
const [search, setSearch] = useState('')
const [status, setStatus] = useState<GerbilStatus | ''>('Active')
const [gender, setGender] = useState<Gender | ''>('')
const [colorVarietyId, setColorVarietyId] = useState('')
const [originBreeder, setOriginBreeder] = useState('')
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
const [showExternal, setShowExternal] = useState(false)
const [sort, setSort] = useState<SortKey>('nameAsc')
const [page, setPage] = useState(1)
const colorVarieties = useApi(() => listColorVarieties(), [])
const colorNameById = useMemo(() => {
const map = new Map<string, string>()
for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name)
return map
}, [colorVarieties.data])
const breeders = useApi(() => listOriginBreeders(), [])
const filter = andFilter(
search.trim() &&
condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: normalizeSearch(search) }),
status && condition({ field: 'status', op: '==', value: status }),
gender && condition({ field: 'gender', op: '==', value: gender }),
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
// restrict to resident animals (server-side, so paging/counts stay correct).
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
)
const orderBy = SORT_ORDER_BY[sort]
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
const gerbils = useApi(() => listGerbils(query), [filter, orderBy, page])
const resetFilters = () => {
setSearch('')
setStatus('Active')
setGender('')
setColorVarietyId('')
setOriginBreeder('')
setShowExternal(false)
setPage(1)
setSort('nameAsc')
}
// When a filter changes, jump back to page 1.
const onFilterChange = <T,>(setter: (v: T) => void) => (value: T) => {
setter(value)
setPage(1)
}
const total = gerbils.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = gerbils.data?.items ?? []
// Multi-select bulk "Zur Abgabe stellen".
const [selected, setSelected] = useState<Set<string>>(new Set())
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const bulkForSale = useMutation((ids: string[]) =>
Promise.all(ids.map((id) => updateGerbil(id, { status: 'ForSale' }))),
)
async function markSelectedForSale() {
const result = await bulkForSale.run([...selected])
if (result.ok) {
setSelected(new Set())
gerbils.reload()
}
}
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{gerbils.data && (
<p className="muted">
{total} {t.countLabel}
</p>
)}
</div>
<Link to="/rennmaeuse/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
<div className="filters">
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
aria-label={t.fields.name}
/>
<label className="field">
<span>{t.filters.status}</span>
<select
value={status}
onChange={(e) => onFilterChange(setStatus)(e.target.value as GerbilStatus | '')}
>
<option value="">{t.filters.all}</option>
{GERBIL_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.filters.gender}</span>
<select
value={gender}
onChange={(e) => onFilterChange(setGender)(e.target.value as Gender | '')}
>
<option value="">{t.filters.all}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>
{genderLabel(g)}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.filters.colorVariety}</span>
<select
value={colorVarietyId}
onChange={(e) => onFilterChange(setColorVarietyId)(e.target.value)}
>
<option value="">{t.filters.all}</option>
{(colorVarieties.data ?? []).map((cv) => (
<option key={cv.id} value={cv.id}>
{cv.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.origin}</span>
<select
value={originBreeder}
onChange={(e) => onFilterChange(setOriginBreeder)(e.target.value)}
>
<option value="">{t.filters.all}</option>
{(breeders.data ?? []).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.filters.sortBy}</span>
<select value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
<option value="nameAsc">{t.sort.nameAsc}</option>
<option value="nameDesc">{t.sort.nameDesc}</option>
<option value="birthDesc">{t.sort.birthDesc}</option>
<option value="birthAsc">{t.sort.birthAsc}</option>
</select>
</label>
<label className="field field--check" title={t.filters.showExternalHint}>
<span>{t.filters.showExternal}</span>
<input
type="checkbox"
checked={showExternal}
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/>
</label>
<button type="button" className="btn" onClick={resetFilters}>
{t.filters.reset}
</button>
</div>
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
{gerbils.error && (
<div className="alert alert--error">
<span>{gerbils.error}</span>
<button type="button" className="btn" onClick={gerbils.reload}>
{de.common.retry}
</button>
</div>
)}
{!gerbils.loading && !gerbils.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{selected.size > 0 && (
<div className="bulk-bar">
<span>
{selected.size} {t.countLabel}
</span>
<button
type="button"
className="btn btn--primary"
disabled={bulkForSale.pending}
onClick={markSelectedForSale}
>
{de.pages.abgabe.markMultiAction}
</button>
{bulkForSale.error && <span className="error-text">{bulkForSale.error}</span>}
</div>
)}
{items.length > 0 && (
<ul className="card-list">
{items.map((g) => (
<li key={g.id} className="gerbil-row">
<input
type="checkbox"
className="gerbil-row__check"
checked={selected.has(g.id)}
onChange={() => toggleSelect(g.id)}
aria-label={g.name}
/>
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name">
{g.name}
{g.isResident === false && (
<span className="badge badge--external" title={t.externalTitle}>
{t.externalBadge}
</span>
)}
</span>
<span className={`badge badge--${g.status.toLowerCase()}`}>
{statusLabel(g.status)}
</span>
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
<span className="gerbil-card__meta">
{g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'}
</span>
<span className="gerbil-card__meta">{formatDate(g.dateOfBirth)}</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>
)}
</section>
)
}