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:
19
gerbil-manager-web/src/format/labels.ts
Normal file
19
gerbil-manager-web/src/format/labels.ts
Normal 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}`
|
||||
}
|
||||
@@ -155,3 +155,195 @@ a {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,90 @@ export const de = {
|
||||
},
|
||||
gerbils: {
|
||||
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 (A–Z)',
|
||||
nameDesc: 'Name (Z–A)',
|
||||
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: {
|
||||
title: 'Würfe',
|
||||
@@ -51,6 +134,15 @@ export const de = {
|
||||
},
|
||||
common: {
|
||||
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,
|
||||
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
||||
|
||||
Reference in New Issue
Block a user