feat(tiere): Eltern direkt in der Tier-Akte bearbeiten (QOL)
Im Tier-Formular gibt es jetzt den Block „Abstammung" mit Wurf-Auswahl und
Vater-/Mutter-Picker — die Eltern müssen nicht mehr über die Wurf-Seite
gesucht und dort editiert werden. Das Datenmodell bleibt unverändert: Eltern
hängen weiterhin am Geburtswurf.
- Backend: PUT /gerbils/{id}/parents schreibt Litter.FatherId/MotherId des
Geburtswurfs. Ohne Wurf wird ein bestehender mit gleichem Elternpaar +
gleichem Datum verknüpft, sonst ein Träger-Wurf angelegt
("Wurf von X + Y", ShowInChronicle=false, IsManual=true).
Geschlechts-Regel wiederverwendet LitterEndpoints.ValidateParents,
Selbstbezug (Tier als eigener Elternteil) wird abgewiesen.
- UI: Vorbelegung aus dem gewählten Wurf, Warnung mit Anzahl der Geschwister
(Eltern gehören dem Wurf → Änderung gilt für alle), Hinweis wenn ein
Wurf-Eintrag angelegt wird. Texte in de.ts.
- Nebenbei: Speichern nutzt im Edit-Modus die Route-Id (PUT /gerbils/{id}
antwortet 204 ohne Body) und der vorher schon rote Spec-Locator
„Würfe als Elternteil" ist auf den Abschnitt eingegrenzt.
- Tests: GerbilParentsTests (9), e2e tiere.spec (2 neu) + Mock-Route.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -936,6 +936,79 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 200, results)
|
||||
}
|
||||
|
||||
// QOL-ELTERN: Eltern direkt an der Tier-Akte setzen (PUT /gerbils/:id/parents).
|
||||
// Spiegelt das Backend: Eltern werden am GEBURTSWURF gespeichert; fehlt der Wurf, wird
|
||||
// ein bestehender mit gleichen Eltern + gleichem Datum verknüpft, sonst einer angelegt
|
||||
// (showInChronicle=false → nicht in der Wurfchronik).
|
||||
const parentsMatch = path.match(/^\/gerbils\/([^/]+)\/parents$/)
|
||||
if (parentsMatch) {
|
||||
if (method !== 'PUT') return json(route, 405)
|
||||
const gid = decodeURIComponent(parentsMatch[1])
|
||||
const g = db.gerbils.find((x) => x.id === gid)
|
||||
if (!g) return json(route, 404, { title: 'Not Found' })
|
||||
const body = (request.postDataJSON() ?? {}) as {
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
}
|
||||
const fatherId = body.fatherId ?? null
|
||||
const motherId = body.motherId ?? null
|
||||
const father = fatherId ? db.gerbils.find((x) => x.id === fatherId) : null
|
||||
const mother = motherId ? db.gerbils.find((x) => x.id === motherId) : null
|
||||
if (father?.gender === 'female' || mother?.gender === 'male') {
|
||||
return json(route, 400, {
|
||||
code: 'InvalidParentGender',
|
||||
fatherGender: father?.gender ?? 'unknown',
|
||||
motherGender: mother?.gender ?? 'unknown',
|
||||
})
|
||||
}
|
||||
let litter = g.litterId ? (db.litters.find((l) => l.id === g.litterId) ?? null) : null
|
||||
let litterCreated = false
|
||||
let litterAttached = false
|
||||
if (!litter && !fatherId && !motherId) {
|
||||
return json(route, 200, {
|
||||
litterId: null, litterName: null, litterCreated: false, litterAttached: false,
|
||||
siblingCount: 0, fatherId: null, motherId: null,
|
||||
})
|
||||
}
|
||||
if (!litter) {
|
||||
if (g.dateOfBirth) {
|
||||
litter =
|
||||
db.litters.find(
|
||||
(l) => l.fatherId === fatherId && l.motherId === motherId && l.date === g.dateOfBirth,
|
||||
) ?? null
|
||||
litterAttached = litter !== null
|
||||
}
|
||||
if (!litter) {
|
||||
litter = {
|
||||
id: newId('litter'),
|
||||
name: `Wurf von ${father?.name ?? '—'} + ${mother?.name ?? '—'}`,
|
||||
date: g.dateOfBirth ?? '',
|
||||
totalBorn: null,
|
||||
expectedGoHomeDate: null,
|
||||
notes: null,
|
||||
fatherId,
|
||||
motherId,
|
||||
showInChronicle: false,
|
||||
}
|
||||
db.litters.push(litter)
|
||||
litterCreated = true
|
||||
}
|
||||
g.litterId = litter.id
|
||||
}
|
||||
litter.fatherId = fatherId
|
||||
litter.motherId = motherId
|
||||
const siblingCount = db.gerbils.filter((x) => x.litterId === litter!.id && x.id !== gid).length
|
||||
return json(route, 200, {
|
||||
litterId: litter.id,
|
||||
litterName: litter.name,
|
||||
litterCreated,
|
||||
litterAttached,
|
||||
siblingCount,
|
||||
fatherId,
|
||||
motherId,
|
||||
})
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import type { Page } from '@playwright/test'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
@@ -202,18 +203,73 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||
})
|
||||
|
||||
/** QOL-ELTERN: Vater/Mutter im Abstammungs-Block des Tier-Formulars setzen. */
|
||||
async function pickParent(page: Page, legend: string, name: string) {
|
||||
const group = page.getByRole('group', { name: legend })
|
||||
await group.getByRole('searchbox').fill(name)
|
||||
await group.getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
test('Tier bearbeiten — Eltern direkt an der Akte setzen, Wurf wird angelegt (QOL-ELTERN)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Maja hat keinen Geburtswurf → beim Speichern wird einer für die Eltern angelegt.
|
||||
await page.goto('/rennmaeuse/maja/bearbeiten')
|
||||
await expect(page.getByRole('heading', { name: t.form.editTitle })).toBeVisible()
|
||||
await expect(page.getByText(t.form.parentsSectionTitle)).toBeVisible()
|
||||
|
||||
await pickParent(page, t.detail.parentLittersRoleVater, 'Anton')
|
||||
await pickParent(page, t.detail.parentLittersRoleMutter, 'Greta')
|
||||
// Hinweis, dass ein Wurf-Eintrag angelegt wird (kein Wurf vorhanden).
|
||||
await expect(page.getByTestId('parents-new-litter-hint')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
|
||||
// Akte zeigt die neuen Eltern als Links.
|
||||
await expect(page.getByRole('heading', { name: 'Maja' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Anton', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Greta', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier bearbeiten — Eltern-Wechsel warnt vor den Geschwistern desselben Wurfs (QOL-ELTERN)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hängt an Wurf K (Vater Fridolin, Mutter Luna) mit 2 weiteren Jungtieren.
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
const vater = page.getByRole('group', { name: t.detail.parentLittersRoleVater })
|
||||
await expect(vater.getByText('Fridolin')).toBeVisible()
|
||||
|
||||
// Vater tauschen → Warnung, dass die Änderung auch für die Geschwister gilt.
|
||||
await vater.getByRole('button', { name: t.form.parentsClear }).click()
|
||||
await pickParent(page, t.detail.parentLittersRoleVater, 'Anton')
|
||||
await expect(page.getByTestId('parents-sibling-warning')).toContainText('2')
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Anton', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
// Wurf K als Link sichtbar — gezielt IM Abschnitt „Würfe als Elternteil": der Abschnitt
|
||||
// „Nachzucht in der Zucht" nennt den Wurfnamen ebenfalls (Krümel-Karte) und würde die
|
||||
// Suche über die ganze Seite mehrdeutig machen.
|
||||
const littersSection = page.locator('section.ak-card', {
|
||||
has: page.getByRole('heading', { name: t.detail.parentLittersTitle }),
|
||||
})
|
||||
const litterLink = littersSection.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
|
||||
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
|
||||
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
|
||||
await expect(
|
||||
littersSection.locator('.badge', { hasText: t.detail.parentLittersRoleVater }).first(),
|
||||
).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/** Typed API functions for the Tiere (Rennmäuse) resource. */
|
||||
import { api, resources } from './client'
|
||||
import { toQueryString, type GridifyQuery } from './gridify'
|
||||
import type { CreateGerbil, Gerbil, Paged, UpdateGerbil } from './types'
|
||||
import type {
|
||||
CreateGerbil,
|
||||
Gerbil,
|
||||
GerbilParentsInput,
|
||||
GerbilParentsResult,
|
||||
Paged,
|
||||
UpdateGerbil,
|
||||
} from './types'
|
||||
|
||||
export function listGerbils(query: GridifyQuery): Promise<Paged<Gerbil>> {
|
||||
return api.get<Paged<Gerbil>>(`${resources.gerbils}${toQueryString(query)}`)
|
||||
@@ -24,6 +31,18 @@ export function updateGerbil(id: string, body: UpdateGerbil): Promise<Gerbil> {
|
||||
return api.put<Gerbil>(`${resources.gerbils}/${id}`, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* QOL-ELTERN: Vater/Mutter direkt an der Tier-Akte setzen. Das Backend schreibt sie an den
|
||||
* Geburtswurf des Tiers (und legt diesen an, wenn es noch keinen gibt) — dieselbe Datenbasis,
|
||||
* die die Wurf-Seite bearbeitet, nur ohne den Umweg über die Wurf-Suche.
|
||||
*/
|
||||
export function updateGerbilParents(
|
||||
id: string,
|
||||
body: GerbilParentsInput,
|
||||
): Promise<GerbilParentsResult> {
|
||||
return api.put<GerbilParentsResult>(`${resources.gerbils}/${id}/parents`, body)
|
||||
}
|
||||
|
||||
export function deleteGerbil(id: string): Promise<void> {
|
||||
return api.delete(`${resources.gerbils}/${id}`)
|
||||
}
|
||||
|
||||
@@ -227,6 +227,30 @@ export interface CreateLitter {
|
||||
showInChronicle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* QOL-ELTERN: Eltern eines Tiers direkt an der Akte setzen (PUT /gerbils/{id}/parents).
|
||||
* Gespeichert werden sie unverändert am GEBURTSWURF des Tiers — der Endpoint kapselt nur,
|
||||
* dass man dafür sonst erst den Wurf suchen müsste.
|
||||
*/
|
||||
export interface GerbilParentsInput {
|
||||
fatherId: string | null
|
||||
motherId: string | null
|
||||
}
|
||||
|
||||
/** Antwort von PUT /gerbils/{id}/parents — welcher Wurf die Eltern jetzt trägt. */
|
||||
export interface GerbilParentsResult {
|
||||
litterId: string | null
|
||||
litterName: string | null
|
||||
/** true = für die Eltern wurde ein (versteckter) Wurf neu angelegt. */
|
||||
litterCreated: boolean
|
||||
/** true = das Tier wurde einem bestehenden Wurf mit denselben Eltern + Datum zugeordnet. */
|
||||
litterAttached: boolean
|
||||
/** Weitere Jungtiere im selben Wurf, für die die Eltern ebenfalls gelten. */
|
||||
siblingCount: number
|
||||
fatherId: string | null
|
||||
motherId: string | null
|
||||
}
|
||||
|
||||
export type UpdateLitter = Partial<CreateLitter>
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMemo, 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 { errorCode } from '../api/client'
|
||||
import { createGerbil, getGerbil, listGerbils, updateGerbil, updateGerbilParents } from '../api/gerbils'
|
||||
import { condition } from '../api/gridify'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { getBreederProfile } from '../api/settings'
|
||||
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||
import {
|
||||
SELECTABLE_STATUSES,
|
||||
type CreateGerbil,
|
||||
type Gender,
|
||||
type GerbilParentsInput,
|
||||
type GerbilStatus,
|
||||
} from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||
import AnimalPicker from '../components/AnimalPicker'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import ColorVarietyPicker from '../components/ColorVarietyPicker'
|
||||
import { useToast } from '../components/toast'
|
||||
@@ -130,6 +139,9 @@ export default function GerbilFormPage() {
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||
// QOL-ELTERN: null = die Eltern folgen dem gewählten Wurf (Vorbelegung). Sobald die
|
||||
// Züchterin hier etwas ändert, hält dieser State die abweichende Auswahl bis zum Speichern.
|
||||
const [parentOverride, setParentOverride] = useState<GerbilParentsInput | null>(null)
|
||||
|
||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
@@ -145,6 +157,7 @@ export default function GerbilFormPage() {
|
||||
if (existing.data && initializedFor !== existing.data.id) {
|
||||
setInitializedFor(existing.data.id)
|
||||
setForm(formFromGerbil(existing.data))
|
||||
setParentOverride(null)
|
||||
}
|
||||
|
||||
// Create-mode prefill from query params (?litterId, ?dob) — used by the Wurf
|
||||
@@ -166,9 +179,48 @@ export default function GerbilFormPage() {
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
// QOL-ELTERN: Abstammung direkt hier pflegen. Eltern hängen im Datenmodell am GEBURTSWURF
|
||||
// des Tiers; vorbelegt werden sie daher aus dem gewählten Wurf. `parentOverride` gewinnt,
|
||||
// sobald die Züchterin etwas ändert.
|
||||
const selectedLitter = useMemo(
|
||||
() => (form.litterId ? ((litters.data ?? []).find((l) => l.id === form.litterId) ?? null) : null),
|
||||
[litters.data, form.litterId],
|
||||
)
|
||||
const litterFatherId = selectedLitter?.fatherId ?? null
|
||||
const litterMotherId = selectedLitter?.motherId ?? null
|
||||
const fatherId = parentOverride ? parentOverride.fatherId : litterFatherId
|
||||
const motherId = parentOverride ? parentOverride.motherId : litterMotherId
|
||||
const parentsChanged = fatherId !== litterFatherId || motherId !== litterMotherId
|
||||
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
|
||||
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
|
||||
|
||||
const setParent = (role: 'father' | 'mother', value: string | null) =>
|
||||
setParentOverride({
|
||||
fatherId: role === 'father' ? value : fatherId,
|
||||
motherId: role === 'mother' ? value : motherId,
|
||||
})
|
||||
|
||||
// Wie viele WEITERE Jungtiere hängen am selben Wurf? Die Eltern gehören dem Wurf, eine
|
||||
// Änderung gilt daher zwangsläufig auch für die Geschwister — davor wird gewarnt.
|
||||
const litterMembers = useApi(
|
||||
() =>
|
||||
form.litterId
|
||||
? listGerbils({
|
||||
filter: condition({ field: 'litterId', op: '==', value: form.litterId }),
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
[form.litterId],
|
||||
)
|
||||
const siblingCount = Math.max(0, (litterMembers.data?.totalCount ?? 0) - (isEdit ? 1 : 0))
|
||||
|
||||
const mutation = useMutation((body: CreateGerbil) =>
|
||||
isEdit && id ? updateGerbil(id, body) : createGerbil(body),
|
||||
)
|
||||
const parentsMutation = useMutation((gerbilId: string, body: GerbilParentsInput) =>
|
||||
updateGerbilParents(gerbilId, body),
|
||||
)
|
||||
|
||||
function validate(): boolean {
|
||||
const next: Partial<Record<keyof FormState, string>> = {}
|
||||
@@ -209,13 +261,33 @@ export default function GerbilFormPage() {
|
||||
isCastrated: form.isCastrated,
|
||||
}
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/rennmaeuse/${result.value.id}`)
|
||||
} else {
|
||||
if (!result.ok) {
|
||||
// mutation.error drives the inline alert; run() never throws.
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
// On failure mutation.error drives the inline alert; run() never throws.
|
||||
// PUT /gerbils/{id} antwortet 204 ohne Body — im Edit-Modus die Route-Id verwenden.
|
||||
const savedId = isEdit && id ? id : result.value?.id
|
||||
if (savedId && parentsChanged) {
|
||||
const parents = await parentsMutation.run(savedId, { fatherId, motherId })
|
||||
if (!parents.ok) {
|
||||
// Auf dem Formular bleiben, damit die Eltern-Eingabe nicht verloren geht.
|
||||
toast.error(
|
||||
errorCode(parents.cause) === 'InvalidParentGender'
|
||||
? de.pages.litters.validation.invalidParentGender
|
||||
: parents.error,
|
||||
)
|
||||
return
|
||||
}
|
||||
const litterName = parents.value?.litterName
|
||||
if (litterName && parents.value?.litterCreated) {
|
||||
toast.success(t.form.parentsLitterCreated(litterName))
|
||||
} else if (litterName && parents.value?.litterAttached) {
|
||||
toast.success(t.form.parentsLitterAttached(litterName))
|
||||
}
|
||||
}
|
||||
toast.success(de.common.saved)
|
||||
if (savedId) navigate(`/rennmaeuse/${savedId}`)
|
||||
}
|
||||
|
||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||
@@ -503,17 +575,82 @@ export default function GerbilFormPage() {
|
||||
</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>
|
||||
{/* QOL-ELTERN: Wurf + Eltern in einem Block. Die Eltern gehören technisch zum
|
||||
Geburtswurf — deshalb stehen sie direkt beim Wurf-Feld, statt dass man erst
|
||||
über die Wurf-Seite gehen muss. */}
|
||||
<div className="form-group-box">
|
||||
<div className="form-group-box__title">{t.form.parentsSectionTitle}</div>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.litter}</span>
|
||||
<select
|
||||
value={form.litterId}
|
||||
onChange={(e) => {
|
||||
set('litterId', e.target.value)
|
||||
// Eltern folgen wieder dem (neu) gewählten Wurf.
|
||||
setParentOverride(null)
|
||||
}}
|
||||
>
|
||||
<option value="">{t.form.none}</option>
|
||||
{(litters.data ?? []).map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Vater/Mutter untereinander — auf dem Handy ist nebeneinander zu eng
|
||||
(die Wurf-Seite stapelt die beiden Picker genauso). */}
|
||||
<fieldset className="parent-selector">
|
||||
<legend>{t.detail.parentLittersRoleVater}</legend>
|
||||
{fatherId ? (
|
||||
<p className="muted parent-selector__animal">
|
||||
{father.data?.name || de.pages.gerbils.nameless}
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setParent('father', null)}
|
||||
>
|
||||
{t.form.parentsClear}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<AnimalPicker gender="male" onPick={(g) => setParent('father', g.id)} />
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="parent-selector">
|
||||
<legend>{t.detail.parentLittersRoleMutter}</legend>
|
||||
{motherId ? (
|
||||
<p className="muted parent-selector__animal">
|
||||
{mother.data?.name || de.pages.gerbils.nameless}
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setParent('mother', null)}
|
||||
>
|
||||
{t.form.parentsClear}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<AnimalPicker gender="female" onPick={(g) => setParent('mother', g.id)} />
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<small className="muted">{t.form.parentsHint}</small>
|
||||
|
||||
{parentsChanged && !form.litterId && (
|
||||
<small className="muted" data-testid="parents-new-litter-hint">
|
||||
{t.form.parentsWillCreateLitter}
|
||||
</small>
|
||||
)}
|
||||
{parentsChanged && selectedLitter && siblingCount > 0 && (
|
||||
<div className="alert alert--warning" data-testid="parents-sibling-warning">
|
||||
{t.form.parentsSiblingWarning(selectedLitter.name, siblingCount)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -555,8 +692,12 @@ export default function GerbilFormPage() {
|
||||
{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
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={mutation.pending || parentsMutation.pending}
|
||||
>
|
||||
{mutation.pending || parentsMutation.pending ? t.form.saving : t.form.save}
|
||||
</button>
|
||||
<Link to={isEdit && id ? `/rennmaeuse/${id}` : '/rennmaeuse'} className="btn">
|
||||
{t.form.cancel}
|
||||
|
||||
@@ -172,6 +172,24 @@ export const de = {
|
||||
isDeafNo: 'Nein',
|
||||
isScheckeLabel: 'Ist eine Schecke',
|
||||
colorSectionTitle: 'Farbe & Genetik',
|
||||
// QOL-ELTERN: Abstammung direkt im Tier-Formular pflegen. Gespeichert werden Vater und
|
||||
// Mutter am Geburtswurf des Tiers — genau dort, wo sie auch die Wurf-Seite schreibt.
|
||||
parentsSectionTitle: 'Abstammung',
|
||||
parentsHint:
|
||||
'Vater und Mutter gehören zum Geburtswurf des Tiers. Hier gepflegt, ohne den Wurf erst suchen zu müssen.',
|
||||
parentsClear: 'Entfernen',
|
||||
parentsUnknown: '— nicht erfasst —',
|
||||
// Warnung: der Wurf hat weitere Jungtiere, die Eltern gelten zwangsläufig für alle.
|
||||
parentsSiblingWarning: (litter: string, count: number) =>
|
||||
count === 1
|
||||
? `Der Wurf „${litter}“ hat noch 1 weiteres Jungtier — geänderte Eltern gelten auch dafür.`
|
||||
: `Der Wurf „${litter}“ hat noch ${count} weitere Jungtiere — geänderte Eltern gelten auch für sie.`,
|
||||
// Hinweis: das Tier hat noch keinen Wurf, für die Eltern wird einer angelegt.
|
||||
parentsWillCreateLitter:
|
||||
'Dieses Tier hat noch keinen Wurf. Beim Speichern wird dafür ein Wurf-Eintrag angelegt — er erscheint nicht in der Wurfchronik, nur in der Abstammung.',
|
||||
parentsLitterCreated: (litter: string) => `Wurf „${litter}“ für die Eltern angelegt.`,
|
||||
parentsLitterAttached: (litter: string) =>
|
||||
`Dem bestehenden Wurf „${litter}“ zugeordnet (gleiche Eltern, gleiches Datum).`,
|
||||
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
|
||||
statusDerivedHint: {
|
||||
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
|
||||
|
||||
Reference in New Issue
Block a user