Files
GerbilManager/gerbil-manager-web/src/pages/WurfFormPage.tsx

251 lines
8.2 KiB
TypeScript

import { useMemo, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { errorCode } from '../api/client'
import { createLitter, getLitter, updateLitter } from '../api/litters'
import { listGerbils } from '../api/gerbils'
import type { CreateLitter } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import AnimalPicker from '../components/AnimalPicker'
interface FormState {
name: string
date: string
fatherId: string
fatherName: string
motherId: string
motherName: string
totalBorn: string
expectedGoHomeDate: string
notes: string
}
const EMPTY: FormState = {
name: '',
date: '',
fatherId: '',
fatherName: '',
motherId: '',
motherName: '',
totalBorn: '',
expectedGoHomeDate: '',
notes: '',
}
/** Add n days to an ISO date ("YYYY-MM-DD"), in UTC to avoid TZ drift. */
function addDays(iso: string, n: number): string {
const [y, m, d] = iso.split('-').map(Number)
if (!y || !m || !d) return ''
const ms = Date.UTC(y, m - 1, d) + n * 86_400_000
return new Date(ms).toISOString().slice(0, 10)
}
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
export default function WurfFormPage() {
const t = de.pages.litters
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 [submitError, setSubmitError] = useState<string | null>(null)
const [goHomeTouched, setGoHomeTouched] = useState(false)
const [initializedFor, setInitializedFor] = useState<string | null>(null)
const existing = useApi(() => (id ? getLitter(id) : Promise.resolve(null)), [id])
// Resolve parent names when editing (litter stores only ids).
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const nameById = useMemo(() => {
const map = new Map<string, string>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
return map
}, [gerbils.data])
// Prefill in edit mode (adjust state during render, guarded).
if (existing.data && initializedFor !== existing.data.id) {
setInitializedFor(existing.data.id)
const l = existing.data
setForm({
name: l.name,
date: l.date ?? '',
fatherId: l.fatherId ?? '',
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
motherId: l.motherId ?? '',
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
notes: l.notes ?? '',
})
setGoHomeTouched(true)
}
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
// Wurfdatum change: auto-suggest go-home +35 days unless the user edited it.
const onDateChange = (date: string) => {
setForm((f) => ({
...f,
date,
expectedGoHomeDate: goHomeTouched ? f.expectedGoHomeDate : date ? addDays(date, 35) : '',
}))
}
const mutation = useMutation((body: CreateLitter) =>
isEdit && id ? updateLitter(id, body) : createLitter(body),
)
function validate(): boolean {
const next: Partial<Record<keyof FormState, string>> = {}
if (form.name.trim() === '') next.name = t.validation.nameRequired
if (form.date.trim() === '') next.date = t.validation.dateRequired
setErrors(next)
return Object.keys(next).length === 0
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!validate()) return
const totalBornNum = form.totalBorn.trim() === '' ? null : Number(form.totalBorn)
const body: CreateLitter = {
name: form.name.trim(),
date: form.date,
fatherId: nn(form.fatherId),
motherId: nn(form.motherId),
totalBorn: totalBornNum != null && Number.isFinite(totalBornNum) ? totalBornNum : null,
expectedGoHomeDate: nn(form.expectedGoHomeDate),
notes: nn(form.notes),
}
setSubmitError(null)
const result = await mutation.run(body)
if (result.ok) {
navigate(`/wuerfe/${result.value.id}`)
return
}
// Localize the backend's parent-gender 400 by its code; else generic message.
setSubmitError(
errorCode(result.cause) === 'InvalidParentGender'
? t.validation.invalidParentGender
: result.error,
)
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
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.date} *</span>
<input
type="date"
className="input"
value={form.date}
onChange={(e) => onDateChange(e.target.value)}
aria-invalid={Boolean(errors.date)}
/>
{errors.date && <small className="error-text">{errors.date}</small>}
</label>
<fieldset className="parent-selector">
<legend>{t.fields.father}</legend>
{form.fatherName ? (
<p className="muted parent-selector__animal">
{form.fatherName}
<button
type="button"
className="link-btn"
onClick={() => setForm((f) => ({ ...f, fatherId: '', fatherName: '' }))}
>
{de.pages.genetik.clearAnimal}
</button>
</p>
) : (
<AnimalPicker
gender="male"
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
/>
)}
</fieldset>
<fieldset className="parent-selector">
<legend>{t.fields.mother}</legend>
{form.motherName ? (
<p className="muted parent-selector__animal">
{form.motherName}
<button
type="button"
className="link-btn"
onClick={() => setForm((f) => ({ ...f, motherId: '', motherName: '' }))}
>
{de.pages.genetik.clearAnimal}
</button>
</p>
) : (
<AnimalPicker
gender="female"
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
/>
)}
</fieldset>
<label className="field">
<span>{t.fields.totalBorn}</span>
<input
type="number"
min="0"
className="input"
value={form.totalBorn}
onChange={(e) => set('totalBorn', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.expectedGoHomeDate}</span>
<input
type="date"
className="input"
value={form.expectedGoHomeDate}
onChange={(e) => {
setGoHomeTouched(true)
set('expectedGoHomeDate', e.target.value)
}}
/>
<small className="muted">{t.form.goHomeHint}</small>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label>
{submitError && <div className="alert alert--error">{submitError}</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 ? `/wuerfe/${id}` : '/wuerfe'} className="btn">
{t.form.cancel}
</Link>
</div>
</form>
</section>
)
}