Files
GerbilManager/gerbil-manager-web/src/pages/GerbilFormPage.tsx
Gulum 0e7ec5ab61 BESTAND-FILTER follow-up: residency toggle on the Gerbil create/edit form
Completes residency end-to-end: a 'Gehört zum eigenen Bestand' checkbox on the
Rennmaus-Formular (default on for new animals) lets the user mark a tier as an
external ancestor by hand — previously isResident was only set at import. Wired
through FormState + formFromGerbil (defaults true) + the CreateGerbil payload.

German via de.ts (LF). e2e: edit Krümel, untick Bestand, save -> detail shows
the 'Extern' badge (proves form field -> payload -> persistence). Gate green:
build, eslint, vitest 78, e2e 86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:32 +02:00

386 lines
13 KiB
TypeScript

import { useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { GENDERS, GERBIL_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage'
interface FormState {
name: string
gender: Gender | ''
status: GerbilStatus
dateOfBirth: string
dateOfDeath: string
causeOfDeath: string
goHomeDate: string
colorVarietyId: string
enclosureId: string
litterId: string
originContactId: string
receiverContactId: string
genotype: string
notes: string
isResident: boolean
}
const EMPTY: FormState = {
name: '',
gender: '',
status: 'Active',
dateOfBirth: '',
dateOfDeath: '',
causeOfDeath: '',
goHomeDate: '',
colorVarietyId: '',
enclosureId: '',
litterId: '',
originContactId: '',
receiverContactId: '',
genotype: '',
notes: '',
isResident: true,
}
function formFromGerbil(g: {
name: string
gender: Gender
status: GerbilStatus
dateOfBirth: string | null
dateOfDeath: string | null
causeOfDeath: string | null
goHomeDate: string | null
colorVarietyId: string | null
enclosureId: string | null
litterId: string | null
originContactId: string | null
receiverContactId: string | null
genotype: string | null
notes: string | null
isResident?: boolean | null
}): FormState {
return {
name: g.name,
gender: g.gender,
status: g.status,
dateOfBirth: g.dateOfBirth ?? '',
dateOfDeath: g.dateOfDeath ?? '',
causeOfDeath: g.causeOfDeath ?? '',
goHomeDate: g.goHomeDate ?? '',
colorVarietyId: g.colorVarietyId ?? '',
enclosureId: g.enclosureId ?? '',
litterId: g.litterId ?? '',
originContactId: g.originContactId ?? '',
receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '',
notes: g.notes ?? '',
isResident: g.isResident ?? true,
}
}
/** "" -> null, else the value. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
function isGenotypeValid(genotype: string): boolean {
if (genotype.trim() === '') return true
try {
fromDisplayString(genotype)
return true
} catch {
return false
}
}
export default function GerbilFormPage() {
const t = de.pages.gerbils
const navigate = useNavigate()
const { id } = useParams()
const isEdit = Boolean(id)
const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [initializedFor, setInitializedFor] = useState<string | null>(null)
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
const colorVarieties = useApi(() => listColorVarieties(), [])
const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), [])
// Prefill from the loaded gerbil once (edit mode). React's "adjust state
// during render" pattern — runs without an effect and re-renders immediately.
if (existing.data && initializedFor !== existing.data.id) {
setInitializedFor(existing.data.id)
setForm(formFromGerbil(existing.data))
}
// Create-mode prefill from query params (?litterId, ?dob) — used by the Wurf
// workspace's "Jungtier erfassen" action to register a pup in one tap.
const [searchParams] = useSearchParams()
if (!isEdit && initializedFor === null) {
const litterId = searchParams.get('litterId')
const dob = searchParams.get('dob')
if (litterId || dob) {
setInitializedFor('new')
setForm((f) => ({
...f,
litterId: litterId ?? f.litterId,
dateOfBirth: dob ?? f.dateOfBirth,
}))
}
}
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const mutation = useMutation((body: CreateGerbil) =>
isEdit && id ? updateGerbil(id, body) : createGerbil(body),
)
function validate(): boolean {
const next: Partial<Record<keyof FormState, string>> = {}
if (form.name.trim() === '') next.name = t.validation.nameRequired
if (form.gender === '') next.gender = t.validation.genderRequired
if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid
if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
}
setErrors(next)
return Object.keys(next).length === 0
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!validate()) return
const body: CreateGerbil = {
name: form.name.trim(),
gender: form.gender as Gender,
status: form.status,
dateOfBirth: nn(form.dateOfBirth),
dateOfDeath: nn(form.dateOfDeath),
causeOfDeath: nn(form.causeOfDeath),
goHomeDate: nn(form.goHomeDate),
colorVarietyId: nn(form.colorVarietyId),
enclosureId: nn(form.enclosureId),
litterId: nn(form.litterId),
originContactId: nn(form.originContactId),
receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype),
notes: nn(form.notes),
isResident: form.isResident,
}
const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
const selectedColorName =
(colorVarieties.data ?? []).find((cv) => cv.id === form.colorVarietyId)?.name ?? null
return (
<section className="page">
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
<form className="form" onSubmit={onSubmit} noValidate>
<label className="field">
<span>{t.fields.name} *</span>
<input
className="input"
value={form.name}
onChange={(e) => set('name', e.target.value)}
aria-invalid={Boolean(errors.name)}
/>
{errors.name && <small className="error-text">{errors.name}</small>}
</label>
<label className="field">
<span>{t.fields.gender} *</span>
<select
value={form.gender}
onChange={(e) => set('gender', e.target.value as Gender | '')}
aria-invalid={Boolean(errors.gender)}
>
<option value="">{t.form.none}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>
{genderLabel(g)}
</option>
))}
</select>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</label>
<label className="field">
<span>{t.fields.status}</span>
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
{GERBIL_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
{form.status === 'Deceased' && (
<>
<label className="field">
<span>{t.fields.dateOfDeath}</span>
<input
type="date"
className="input"
value={form.dateOfDeath}
onChange={(e) => set('dateOfDeath', e.target.value)}
aria-invalid={Boolean(errors.dateOfDeath)}
/>
{errors.dateOfDeath && <small className="error-text">{errors.dateOfDeath}</small>}
</label>
<label className="field">
<span>{t.fields.causeOfDeath}</span>
<input
className="input"
value={form.causeOfDeath}
onChange={(e) => set('causeOfDeath', e.target.value)}
/>
</label>
</>
)}
{form.status === 'GivenAway' && (
<label className="field">
<span>{t.fields.goHomeDate}</span>
<input
type="date"
className="input"
value={form.goHomeDate}
onChange={(e) => set('goHomeDate', e.target.value)}
/>
</label>
)}
<label className="field">
<span>{t.fields.colorVariety}</span>
<span className="farbschlag-value">
<select
value={form.colorVarietyId}
onChange={(e) => set('colorVarietyId', e.target.value)}
>
<option value="">{t.form.none}</option>
{(colorVarieties.data ?? []).map((cv) => (
<option key={cv.id} value={cv.id}>
{cv.name}
</option>
))}
</select>
{selectedColorName && <FarbschlagImage name={selectedColorName} size={36} />}
</span>
</label>
<label className="field">
<span>{t.fields.enclosure}</span>
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
<option value="">{t.form.none}</option>
{(enclosures.data ?? []).map((en) => (
<option key={en.id} value={en.id}>
{en.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.litter}</span>
<select value={form.litterId} onChange={(e) => set('litterId', e.target.value)}>
<option value="">{t.form.none}</option>
{(litters.data ?? []).map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.receiver}</span>
<select
value={form.receiverContactId}
onChange={(e) => set('receiverContactId', e.target.value)}
>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => set('genotype', e.target.value)}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>
<input
type="checkbox"
checked={form.isResident}
onChange={(e) => set('isResident', e.target.checked)}
/>
</label>
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? t.form.saving : t.form.save}
</button>
<Link to={isEdit && id ? `/rennmaeuse/${id}` : '/rennmaeuse'} className="btn">
{t.form.cancel}
</Link>
</div>
</form>
</section>
)
}