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:
2026-06-05 23:54:44 +02:00
parent f162ef4fed
commit 9b586dd7fa
4 changed files with 504 additions and 4 deletions

View File

@@ -0,0 +1,19 @@
/** German label helpers for enum values and lookups. */
import { de } from '../strings/de'
import type { Gender, GerbilStatus } from '../api/types'
export function genderLabel(gender: Gender): string {
return de.pages.gerbils.genderLabels[gender]
}
export function statusLabel(status: GerbilStatus): string {
return de.pages.gerbils.statusLabels[status]
}
/** Format an ISO date ("YYYY-MM-DD") as German "TT.MM.JJJJ"; null -> dash. */
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
const [y, m, d] = iso.split('-')
if (!y || !m || !d) return iso
return `${d}.${m}.${y}`
}

View File

@@ -155,3 +155,195 @@ a {
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
} }
} }
/* ── Shared UI ───────────────────────────────────────────────── */
.muted {
color: var(--color-text-muted);
margin: 0.25rem 0;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
cursor: pointer;
text-decoration: none;
}
.btn:hover:not(:disabled) {
background: var(--color-accent-soft);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.btn--primary:hover:not(:disabled) {
filter: brightness(0.95);
background: var(--color-accent);
}
.btn--danger {
border-color: #b54a4a;
color: #b54a4a;
}
.input,
select,
textarea {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
min-height: 44px;
width: 100%;
}
textarea {
min-height: 5rem;
resize: vertical;
}
.alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin: 0.75rem 0;
}
.alert--error {
background: #fbeaea;
border: 1px solid #e3b3b3;
color: #8a2f2f;
}
/* ── Filters ─────────────────────────────────────────────────── */
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin: 1rem 0;
}
.filters .input[type='search'] {
flex: 1 1 12rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.field > select,
.field > .input {
min-width: 9rem;
}
/* ── Card list (mobile-first); table-like rows on wider screens ── */
.card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gerbil-card {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem 0.75rem;
align-items: center;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background: var(--color-surface);
text-decoration: none;
color: var(--color-text);
}
.gerbil-card:hover {
background: var(--color-accent-soft);
}
.gerbil-card__name {
font-weight: 600;
}
.gerbil-card__meta {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.badge {
justify-self: end;
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: var(--color-accent-soft);
color: var(--color-accent);
white-space: nowrap;
}
.badge--deceased {
background: #ececec;
color: #666;
}
.badge--givenaway {
background: #e7eef6;
color: #3a6ea5;
}
@media (min-width: 768px) {
.gerbil-card {
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
grid-auto-flow: column;
}
.badge {
justify-self: start;
}
}
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin: 1.5rem 0;
}

View File

@@ -1,10 +1,207 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de' 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() { 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 ( return (
<section> <section className="page">
<h2>{de.pages.gerbils.title}</h2> <header className="page-head">
<p>{de.pages.gerbils.comingSoon}</p> <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> </section>
) )
} }

View File

@@ -25,7 +25,90 @@ export const de = {
}, },
gerbils: { gerbils: {
title: 'Rennmäuse', title: 'Rennmäuse',
comingSoon: 'Die Rennmaus-Verwaltung entsteht gerade. Bald kannst du hier deine Tiere anlegen und pflegen.', // Liste
newButton: 'Neue Rennmaus',
empty: 'Keine Rennmäuse gefunden.',
countLabel: 'Tiere',
searchPlaceholder: 'Name suchen …',
// Filter-Beschriftungen
filters: {
title: 'Filter',
status: 'Status',
gender: 'Geschlecht',
colorVariety: 'Farbschlag',
all: 'Alle',
reset: 'Filter zurücksetzen',
sortBy: 'Sortieren nach',
},
// Sortier-Optionen
sort: {
nameAsc: 'Name (AZ)',
nameDesc: 'Name (ZA)',
birthDesc: 'Geburtsdatum (neueste)',
birthAsc: 'Geburtsdatum (älteste)',
},
// Feld-Beschriftungen (Stammdaten)
fields: {
name: 'Name',
gender: 'Geschlecht',
status: 'Status',
dateOfBirth: 'Geburtsdatum',
dateOfDeath: 'Todesdatum',
causeOfDeath: 'Todesursache',
goHomeDate: 'Abgabedatum',
litter: 'Wurf',
enclosure: 'Becken',
colorVariety: 'Farbschlag',
origin: 'Herkunft',
receiver: 'Abnehmer',
genotype: 'Genotyp',
notes: 'Notizen',
},
// Detailseite
detail: {
masterData: 'Stammdaten',
genetics: 'Genetik',
resolvedFarbschlag: 'Errechneter Farbschlag',
genotypeNotSet: 'Kein Genotyp hinterlegt.',
edit: 'Bearbeiten',
back: 'Zurück zur Liste',
tabs: {
photos: 'Fotos',
health: 'Gesundheit',
weight: 'Gewicht',
},
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.',
},
// Formular (anlegen/bearbeiten)
form: {
createTitle: 'Neue Rennmaus anlegen',
editTitle: 'Rennmaus bearbeiten',
none: '— keine Angabe —',
genotypeHint:
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Speichern …',
},
// Validierungsmeldungen
validation: {
nameRequired: 'Bitte einen Namen eingeben.',
genderRequired: 'Bitte ein Geschlecht auswählen.',
genotypeInvalid: 'Der Genotyp ist ungültig.',
dateOfDeathBeforeBirth: 'Das Todesdatum darf nicht vor dem Geburtsdatum liegen.',
},
// Aufzählungen
genderLabels: {
unknown: 'Unbekannt',
male: 'Männlich',
female: 'Weiblich',
},
statusLabels: {
Active: 'Aktiv',
Deceased: 'Verstorben',
GivenAway: 'Abgegeben',
},
}, },
litters: { litters: {
title: 'Würfe', title: 'Würfe',
@@ -51,6 +134,15 @@ export const de = {
}, },
common: { common: {
loading: 'Lädt …', loading: 'Lädt …',
retry: 'Erneut versuchen',
previous: 'Zurück',
next: 'Weiter',
page: 'Seite',
of: 'von',
yes: 'Ja',
no: 'Nein',
delete: 'Löschen',
confirmDelete: 'Wirklich löschen?',
}, },
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES, // Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte. // die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.