diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 21094ea..a6b0ecf 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -2,6 +2,8 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import AppShell from './components/AppShell' import BreedersPage from './pages/BreedersPage' import GerbilsPage from './pages/GerbilsPage' +import GerbilDetailPage from './pages/GerbilDetailPage' +import GerbilFormPage from './pages/GerbilFormPage' import HomePage from './pages/HomePage' import LittersPage from './pages/LittersPage' import NotFoundPage from './pages/NotFoundPage' @@ -12,7 +14,12 @@ export default function App() { }> } /> - } /> + + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/gerbil-manager-web/src/index.css b/gerbil-manager-web/src/index.css index 77ed128..5d00c61 100644 --- a/gerbil-manager-web/src/index.css +++ b/gerbil-manager-web/src/index.css @@ -431,3 +431,29 @@ textarea { white-space: nowrap; 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; +} diff --git a/gerbil-manager-web/src/pages/GerbilFormPage.tsx b/gerbil-manager-web/src/pages/GerbilFormPage.tsx new file mode 100644 index 0000000..7356c86 --- /dev/null +++ b/gerbil-manager-web/src/pages/GerbilFormPage.tsx @@ -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(EMPTY) + const [errors, setErrors] = useState>>({}) + const [initializedFor, setInitializedFor] = useState(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 = (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> = {} + 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

{de.common.loading}

+ + return ( +
+

{isEdit ? t.form.editTitle : t.form.createTitle}

+ +
+ + + + + + + + + {form.status === 'Deceased' && ( + <> + + + + )} + + {form.status === 'GivenAway' && ( + + )} + + + + + + + + + + + + + +