FEAT-1: Tiere create/edit form (German labels, validation, GEN-1 genotype check) + nested routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
|||||||
import AppShell from './components/AppShell'
|
import AppShell from './components/AppShell'
|
||||||
import BreedersPage from './pages/BreedersPage'
|
import BreedersPage from './pages/BreedersPage'
|
||||||
import GerbilsPage from './pages/GerbilsPage'
|
import GerbilsPage from './pages/GerbilsPage'
|
||||||
|
import GerbilDetailPage from './pages/GerbilDetailPage'
|
||||||
|
import GerbilFormPage from './pages/GerbilFormPage'
|
||||||
import HomePage from './pages/HomePage'
|
import HomePage from './pages/HomePage'
|
||||||
import LittersPage from './pages/LittersPage'
|
import LittersPage from './pages/LittersPage'
|
||||||
import NotFoundPage from './pages/NotFoundPage'
|
import NotFoundPage from './pages/NotFoundPage'
|
||||||
@@ -12,7 +14,12 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="rennmaeuse" element={<GerbilsPage />} />
|
<Route path="rennmaeuse">
|
||||||
|
<Route index element={<GerbilsPage />} />
|
||||||
|
<Route path="neu" element={<GerbilFormPage />} />
|
||||||
|
<Route path=":id" element={<GerbilDetailPage />} />
|
||||||
|
<Route path=":id/bearbeiten" element={<GerbilFormPage />} />
|
||||||
|
</Route>
|
||||||
<Route path="wuerfe" element={<LittersPage />} />
|
<Route path="wuerfe" element={<LittersPage />} />
|
||||||
<Route path="zuechter" element={<BreedersPage />} />
|
<Route path="zuechter" element={<BreedersPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|||||||
@@ -431,3 +431,29 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Form ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form .field > span {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #b54a4a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
344
gerbil-manager-web/src/pages/GerbilFormPage.tsx
Normal file
344
gerbil-manager-web/src/pages/GerbilFormPage.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } 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'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: FormState = {
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
status: 'Active',
|
||||||
|
dateOfBirth: '',
|
||||||
|
dateOfDeath: '',
|
||||||
|
causeOfDeath: '',
|
||||||
|
goHomeDate: '',
|
||||||
|
colorVarietyId: '',
|
||||||
|
enclosureId: '',
|
||||||
|
litterId: '',
|
||||||
|
originContactId: '',
|
||||||
|
receiverContactId: '',
|
||||||
|
genotype: '',
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}): 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 ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "" -> 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
const saved = await mutation.run(body)
|
||||||
|
navigate(`/rennmaeuse/${saved.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user