FEAT-5: add gender-filtered animal pickers to Probeverpaarung parents
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
gerbil-manager-web/src/components/AnimalPicker.tsx
Normal file
73
gerbil-manager-web/src/components/AnimalPicker.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { listGerbils } from '../api/gerbils'
|
||||||
|
import { andFilter, condition } from '../api/gridify'
|
||||||
|
import type { Gender, Gerbil, Paged } from '../api/types'
|
||||||
|
import { useApi } from '../hooks/useApi'
|
||||||
|
|
||||||
|
const EMPTY_PAGE: Paged<Gerbil> = { items: [], totalCount: 0, page: 1, pageSize: 0 }
|
||||||
|
|
||||||
|
export interface AnimalPickerProps {
|
||||||
|
/** Restrict results to this gender (Vater = male, Mutter = female). */
|
||||||
|
gender: Gender
|
||||||
|
onPick: (gerbil: Gerbil) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search existing animals (gender-filtered) and pick one as a breeding parent. */
|
||||||
|
export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
|
||||||
|
const t = de.pages.genetik
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const trimmed = search.trim()
|
||||||
|
const filter = andFilter(
|
||||||
|
trimmed && condition({ field: 'name', op: 'contains', value: trimmed }),
|
||||||
|
condition({ field: 'gender', op: '==', value: gender }),
|
||||||
|
)
|
||||||
|
const result = useApi(
|
||||||
|
() => (trimmed ? listGerbils({ filter, orderBy: 'name', page: 1, pageSize: 8 }) : Promise.resolve(EMPTY_PAGE)),
|
||||||
|
[filter, trimmed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = result.data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animal-picker">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="input"
|
||||||
|
placeholder={t.pickAnimalSearch}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
aria-label={t.pickAnimal}
|
||||||
|
/>
|
||||||
|
{open && trimmed !== '' && (
|
||||||
|
<ul className="animal-picker__results">
|
||||||
|
{result.loading && <li className="muted animal-picker__hint">{de.common.loading}</li>}
|
||||||
|
{!result.loading && items.length === 0 && (
|
||||||
|
<li className="muted animal-picker__hint">{t.pickAnimalNone}</li>
|
||||||
|
)}
|
||||||
|
{items.map((g) => (
|
||||||
|
<li key={g.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="animal-picker__item"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(g)
|
||||||
|
setSearch('')
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ export default function ParentSelector({ label, value, onChange, picker }: Paren
|
|||||||
<small className="muted">
|
<small className="muted">
|
||||||
{t.genotypePreview}: <strong>{preview}</strong>
|
{t.genotypePreview}: <strong>{preview}</strong>
|
||||||
</small>
|
</small>
|
||||||
|
) : value.animalName ? (
|
||||||
|
<small className="error-text">{t.pickAnimalNoGenotype}</small>
|
||||||
) : (
|
) : (
|
||||||
<small className="muted">{t.genotypeHint}</small>
|
<small className="muted">{t.genotypeHint}</small>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -560,3 +560,40 @@ textarea {
|
|||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: 0.3rem;
|
border-radius: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animal-picker {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animal-picker__results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animal-picker__item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animal-picker__item:hover {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animal-picker__hint {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { breed, fromDisplayString, type BreedingResult } from '../genetics'
|
import { breed, fromDisplayString, type BreedingResult } from '../genetics'
|
||||||
import ParentSelector, { type ParentValue } from '../components/ParentSelector'
|
import ParentSelector, { type ParentValue } from '../components/ParentSelector'
|
||||||
|
import AnimalPicker from '../components/AnimalPicker'
|
||||||
import { isValidGenotype } from '../format/genotypeText'
|
import { isValidGenotype } from '../format/genotypeText'
|
||||||
|
|
||||||
const EMPTY_PARENT: ParentValue = { genotype: '', animalName: null }
|
const EMPTY_PARENT: ParentValue = { genotype: '', animalName: null }
|
||||||
@@ -32,8 +33,28 @@ export default function GenetikPage() {
|
|||||||
<p className="muted">{t.subtitle}</p>
|
<p className="muted">{t.subtitle}</p>
|
||||||
|
|
||||||
<div className="parents-grid">
|
<div className="parents-grid">
|
||||||
<ParentSelector label={t.father} value={father} onChange={setFather} />
|
<ParentSelector
|
||||||
<ParentSelector label={t.mother} value={mother} onChange={setMother} />
|
label={t.father}
|
||||||
|
value={father}
|
||||||
|
onChange={setFather}
|
||||||
|
picker={
|
||||||
|
<AnimalPicker
|
||||||
|
gender="male"
|
||||||
|
onPick={(g) => setFather({ genotype: g.genotype ?? '', animalName: g.name })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ParentSelector
|
||||||
|
label={t.mother}
|
||||||
|
value={mother}
|
||||||
|
onChange={setMother}
|
||||||
|
picker={
|
||||||
|
<AnimalPicker
|
||||||
|
gender="female"
|
||||||
|
onPick={(g) => setMother({ genotype: g.genotype ?? '', animalName: g.name })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!bothValid && <p className="muted">{t.needBoth}</p>}
|
{!bothValid && <p className="muted">{t.needBoth}</p>}
|
||||||
|
|||||||
Reference in New Issue
Block a user