SEARCH-2b: Herkunft dropdown -> originBreeder + GET /gerbils/breeders; name search -> nameSearch (client-normalized)

This commit is contained in:
2026-06-06 09:15:54 +02:00
parent ba81f48e0f
commit 681e8c28fb
3 changed files with 31 additions and 16 deletions

View File

@@ -11,6 +11,11 @@ export function getGerbil(id: string): Promise<Gerbil> {
return api.get<Gerbil>(`${resources.gerbils}/${id}`) return api.get<Gerbil>(`${resources.gerbils}/${id}`)
} }
/** SEARCH-2b: distinct non-empty originBreeder (Herkunft) values, sorted. */
export function listOriginBreeders(): Promise<string[]> {
return api.get<string[]>(`${resources.gerbils}/breeders`)
}
export function createGerbil(body: CreateGerbil): Promise<Gerbil> { export function createGerbil(body: CreateGerbil): Promise<Gerbil> {
return api.post<Gerbil>(resources.gerbils, body) return api.post<Gerbil>(resources.gerbils, body)
} }

View File

@@ -31,6 +31,13 @@ export interface Gerbil {
colorVarietyId: string | null colorVarietyId: string | null
originContactId: string | null originContactId: string | null
receiverContactId: string | null receiverContactId: string | null
/** SEARCH-1/2b: free-text Herkunft (Züchter) from the import; Gridify-filterable. */
originBreeder?: string | null
/**
* SEARCH-1/2b: normalized name (lowercase, [\s._-] stripped) — filter-only,
* not part of the returned payload; present here so it can be a Gridify field.
*/
nameSearch?: string | null
/** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */ /** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */
genotype: string | null genotype: string | null
notes: string | null notes: string | null
@@ -50,6 +57,7 @@ export interface CreateGerbil {
colorVarietyId?: string | null colorVarietyId?: string | null
originContactId?: string | null originContactId?: string | null
receiverContactId?: string | null receiverContactId?: string | null
originBreeder?: string | null
genotype?: string | null genotype?: string | null
notes?: string | null notes?: string | null
} }

View File

@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { listGerbils, updateGerbil } from '../api/gerbils' import { listGerbils, listOriginBreeders, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { andFilter, condition, type GridifyQuery } from '../api/gridify' import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
@@ -11,10 +11,11 @@ import './gerbils.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
// SEARCH-2: name search field. Switches to Pam's separator-insensitive // SEARCH-2b: separator-insensitive search via Pam's normalized field. The term
// normalized field once SEARCH-1 lands (see conversation SEARCH-1); 'name' // is normalized the SAME way server-side stores it (lowercase + strip [\s._-]),
// works today via the contains hotfix. // so 'clan kleine chaoten' matches 'clan-kleine-chaoten'.
const NAME_SEARCH_FIELD = 'name' const NAME_SEARCH_FIELD = 'nameSearch'
const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '')
type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc' type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc'
const SORT_ORDER_BY: Record<SortKey, string> = { const SORT_ORDER_BY: Record<SortKey, string> = {
@@ -30,7 +31,7 @@ export default function GerbilsPage() {
const [status, setStatus] = useState<GerbilStatus | ''>('Active') const [status, setStatus] = useState<GerbilStatus | ''>('Active')
const [gender, setGender] = useState<Gender | ''>('') const [gender, setGender] = useState<Gender | ''>('')
const [colorVarietyId, setColorVarietyId] = useState('') const [colorVarietyId, setColorVarietyId] = useState('')
const [originContactId, setOriginContactId] = useState('') const [originBreeder, setOriginBreeder] = useState('')
const [sort, setSort] = useState<SortKey>('nameAsc') const [sort, setSort] = useState<SortKey>('nameAsc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -40,14 +41,15 @@ export default function GerbilsPage() {
for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name) for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name)
return map return map
}, [colorVarieties.data]) }, [colorVarieties.data])
const contacts = useApi(() => listContacts(), []) const breeders = useApi(() => listOriginBreeders(), [])
const filter = andFilter( const filter = andFilter(
search.trim() && condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: search.trim() }), search.trim() &&
condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: normalizeSearch(search) }),
status && condition({ field: 'status', op: '==', value: status }), status && condition({ field: 'status', op: '==', value: status }),
gender && condition({ field: 'gender', op: '==', value: gender }), gender && condition({ field: 'gender', op: '==', value: gender }),
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }), colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
originContactId && condition({ field: 'originContactId', op: '==', value: originContactId }), originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
) )
const orderBy = SORT_ORDER_BY[sort] const orderBy = SORT_ORDER_BY[sort]
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
@@ -59,7 +61,7 @@ export default function GerbilsPage() {
setStatus('Active') setStatus('Active')
setGender('') setGender('')
setColorVarietyId('') setColorVarietyId('')
setOriginContactId('') setOriginBreeder('')
setPage(1) setPage(1)
setSort('nameAsc') setSort('nameAsc')
} }
@@ -164,13 +166,13 @@ export default function GerbilsPage() {
<label className="field"> <label className="field">
<span>{t.fields.origin}</span> <span>{t.fields.origin}</span>
<select <select
value={originContactId} value={originBreeder}
onChange={(e) => onFilterChange(setOriginContactId)(e.target.value)} onChange={(e) => onFilterChange(setOriginBreeder)(e.target.value)}
> >
<option value="">{t.filters.all}</option> <option value="">{t.filters.all}</option>
{(contacts.data ?? []).map((c) => ( {(breeders.data ?? []).map((b) => (
<option key={c.id} value={c.id}> <option key={b} value={b}>
{c.name} {b}
</option> </option>
))} ))}
</select> </select>