- de.ts: nameless = '(ohne Namen)' (neuer Platzhalter-String) - GerbilsPage: g.name || t.nameless in Karten-Name + Checkbox-Aria - GerbilDetailPage: g.name || nameless im h2-Header; g.causeOfDeath/notes via || null (leer-string-sicher wie Herkunft) - AnimalPicker: g.name || nameless im Picker-Button e2e: +1 nameless-stub in mock-data; 2 neue Tests (Liste + Detail-Header) Gate: vitest 82/82, e2e 130/130, tsc clean
310 lines
11 KiB
TypeScript
310 lines
11 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 { FilterPanel } from '../components/FilterPanel'
|
|
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 ?? []
|
|
|
|
// UX-MOBILE-1: count non-default filter values for the badge.
|
|
const activeFilterCount =
|
|
(status !== 'Active' ? 1 : 0) +
|
|
(gender !== '' ? 1 : 0) +
|
|
(colorVarietyId !== '' ? 1 : 0) +
|
|
(originBreeder !== '' ? 1 : 0) +
|
|
(showExternal ? 1 : 0)
|
|
|
|
// 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>
|
|
|
|
<FilterPanel
|
|
searchField={
|
|
<input
|
|
type="search"
|
|
className="input"
|
|
placeholder={t.searchPlaceholder}
|
|
value={search}
|
|
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
|
aria-label={t.fields.name}
|
|
/>
|
|
}
|
|
activeCount={activeFilterCount}
|
|
onReset={resetFilters}
|
|
>
|
|
<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>
|
|
</FilterPanel>
|
|
|
|
{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 || t.nameless}
|
|
/>
|
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
|
<span className="gerbil-card__name">
|
|
{g.name || t.nameless}
|
|
{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>
|
|
)
|
|
}
|