FEAT-1: Tiere list page — Gridify filters (Status/Geschlecht/Farbschlag), sort, paging + German strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,207 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listGerbils } 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 } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
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 [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 filter = andFilter(
|
||||
search.trim() && condition({ field: 'name', op: 'contains', value: search.trim() }),
|
||||
status && condition({ field: 'status', op: '==', value: status }),
|
||||
gender && condition({ field: 'gender', op: '==', value: gender }),
|
||||
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
||||
)
|
||||
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('')
|
||||
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 ?? []
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>{de.pages.gerbils.title}</h2>
|
||||
<p>{de.pages.gerbils.comingSoon}</p>
|
||||
<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.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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{items.map((g) => (
|
||||
<li key={g.id}>
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{g.name}</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="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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user