feat(tiere): Eltern direkt in der Tier-Akte bearbeiten (QOL)
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m15s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m37s
CI / Docker Build & Push (push) Successful in 7m36s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 8s

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:
2026-08-19 20:43:28 +02:00
parent 7a2feac04d
commit 54e873c115
11 changed files with 653 additions and 29 deletions

View File

@@ -286,6 +286,21 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
(Buchstaben verschieben sich bei geänderter Clusterbildung) — rids aus dem Ticket-`agentContext`.
Triage-Helfer `tools/import/rpro3_lookup.py` (Name- oder `--rids`-Modus) zieht Farbe/Gencode/
Eltern/Nachzucht+Partner aus `_rpro3.db` für die Rückfragen an die Züchterin.
- **Eltern an der Tier-Akte bearbeiten (QOL)**: Im Tier-Formular gibt es den Block
**„Abstammung"** (`gerbil-manager-web/src/pages/GerbilFormPage.tsx`) mit Wurf-Auswahl +
Vater/Mutter-Picker (`components/AnimalPicker`), damit die Züchterin die Eltern **ohne** den
Umweg über die Wurf-Seite pflegen kann. **Datenmodell unverändert**: Eltern hängen weiter am
Geburtswurf. Backend `PUT /gerbils/{id}/parents` (`GerbilEndpoints`, DTOs `GerbilParentsInput`/
`GerbilParentsResult`, Tests `GerbilManager.Tests/GerbilParentsTests.cs`) schreibt genau dorthin:
hat das Tier einen Wurf → dessen `FatherId`/`MotherId` (gilt zwangsläufig für alle Geschwister,
die UI warnt mit `siblingCount`); hat es keinen → ein Wurf mit **gleichem Elternpaar + gleichem
Datum** wird verknüpft (`litterAttached`), sonst wird ein Träger-Wurf angelegt (`litterCreated`,
Name „Wurf von X + Y", `ShowInChronicle=false`, `IsManual=true`). Geschlechts-Validierung
wiederverwendet `LitterEndpoints.ValidateParents` (400 `InvalidParentGender`).
**Grenze:** Eltern/Abstammung sind vom Freeze **ausgenommen** (`GerbilSnapshotService.FreezeFields`
ohne `litterId`) → an einem **importierten** Wurf überlebt eine Eltern-Korrektur den Re-Ingest
**nicht** (gleiches Verhalten wie beim Editieren über die Wurf-Seite; dauerhaft nur über den
Importer/`conflict-decisions.json`).
- **Manuell ergänzte Würfe / versteckte Würfe**: `conflict-decisions.json` kennt neben
`resolutions`/`addAnimals` auch **`addLitters`** [{name, mother(+motherDob), father, children[],
showInChronicle, totalBorn, date, notes}] — materialisiert in `merge_and_resolve.py` als

View File

@@ -0,0 +1,188 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>
/// QOL-ELTERN: PUT /gerbils/{id}/parents — Eltern direkt an der Tier-Akte pflegen.
/// Das Datenmodell bleibt unverändert (Eltern hängen am Geburtswurf), der Endpoint
/// schreibt nur dorthin bzw. legt den Träger-Wurf an, wenn es noch keinen gibt.
/// </summary>
public class GerbilParentsTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilParentsTests(ApiFactory factory) => _client = factory.CreateClient();
private async Task<string> CreateGerbil(string name, string gender, string? dob = null, string? litterId = null)
{
var resp = await _client.PostAsJsonAsync("/gerbils", new
{
name,
gender,
dateOfBirth = dob,
litterId,
});
resp.EnsureSuccessStatusCode();
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
.GetProperty("id").GetString()!;
}
private async Task<string> CreateLitter(string name, string date, string? fatherId = null, string? motherId = null)
{
var resp = await _client.PostAsJsonAsync("/litters", new { name, date, fatherId, motherId });
resp.EnsureSuccessStatusCode();
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
.GetProperty("id").GetString()!;
}
private async Task<JsonElement> SetParents(string gerbilId, string? fatherId, string? motherId)
{
var resp = await _client.PutAsJsonAsync($"/gerbils/{gerbilId}/parents", new { fatherId, motherId });
resp.EnsureSuccessStatusCode();
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
}
private async Task<JsonElement> GetLitter(string litterId) =>
JsonDocument.Parse(await _client.GetStringAsync($"/litters/{litterId}")).RootElement;
private static string? Str(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
[Fact]
public async Task Parents_of_existing_litter_are_written_to_that_litter()
{
var father = await CreateGerbil("Vater A", "male", "2024-01-01");
var mother = await CreateGerbil("Mutter A", "female", "2024-01-01");
var litterId = await CreateLitter("A-Wurf", "2025-03-01");
var pup = await CreateGerbil("Jungtier A", "female", "2025-03-01", litterId);
var result = await SetParents(pup, father, mother);
Assert.Equal(litterId, Str(result, "litterId"));
Assert.False(result.GetProperty("litterCreated").GetBoolean());
Assert.False(result.GetProperty("litterAttached").GetBoolean());
var litter = await GetLitter(litterId);
Assert.Equal(father, Str(litter, "fatherId"));
Assert.Equal(mother, Str(litter, "motherId"));
}
[Fact]
public async Task SiblingCount_reports_the_other_pups_the_change_applies_to()
{
var litterId = await CreateLitter("B-Wurf", "2025-04-01");
var pup = await CreateGerbil("Jungtier B1", "female", "2025-04-01", litterId);
await CreateGerbil("Jungtier B2", "male", "2025-04-01", litterId);
await CreateGerbil("Jungtier B3", "male", "2025-04-01", litterId);
var mother = await CreateGerbil("Mutter B", "female", "2024-02-01");
var result = await SetParents(pup, null, mother);
Assert.Equal(2, result.GetProperty("siblingCount").GetInt32());
}
[Fact]
public async Task Gerbil_without_litter_gets_a_hidden_carrier_litter()
{
var father = await CreateGerbil("Vater C", "male", "2024-03-01");
var mother = await CreateGerbil("Mutter C", "female", "2024-03-01");
var orphan = await CreateGerbil("Waise C", "female", "2025-05-01");
var result = await SetParents(orphan, father, mother);
Assert.True(result.GetProperty("litterCreated").GetBoolean());
var litterId = Str(result, "litterId")!;
Assert.Equal("Wurf von Vater C + Mutter C", Str(result, "litterName"));
var litter = await GetLitter(litterId);
Assert.Equal(father, Str(litter, "fatherId"));
Assert.Equal(mother, Str(litter, "motherId"));
// Nur zur Abstammung — der Hilfs-Wurf darf die Wurfchronik nicht zumüllen.
Assert.False(litter.GetProperty("showInChronicle").GetBoolean());
// Wurfdatum = Geburtsdatum des Tiers.
Assert.Equal("2025-05-01", Str(litter, "date"));
// Das Tier hängt jetzt an diesem Wurf.
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
Assert.Equal(litterId, Str(gerbil, "litterId"));
}
[Fact]
public async Task Same_parents_and_same_dob_attach_to_the_existing_litter_instead_of_duplicating()
{
var father = await CreateGerbil("Vater D", "male", "2024-04-01");
var mother = await CreateGerbil("Mutter D", "female", "2024-04-01");
var first = await CreateGerbil("Waise D1", "female", "2025-06-01");
var second = await CreateGerbil("Waise D2", "male", "2025-06-01");
var created = await SetParents(first, father, mother);
Assert.True(created.GetProperty("litterCreated").GetBoolean());
var attached = await SetParents(second, father, mother);
Assert.False(attached.GetProperty("litterCreated").GetBoolean());
Assert.True(attached.GetProperty("litterAttached").GetBoolean());
// Beide sind jetzt Geschwister im selben Wurf.
Assert.Equal(Str(created, "litterId"), Str(attached, "litterId"));
Assert.Equal(1, attached.GetProperty("siblingCount").GetInt32());
}
[Fact]
public async Task Clearing_parents_nulls_them_on_the_litter()
{
var father = await CreateGerbil("Vater E", "male", "2024-05-01");
var litterId = await CreateLitter("E-Wurf", "2025-07-01", father);
var pup = await CreateGerbil("Jungtier E", "female", "2025-07-01", litterId);
await SetParents(pup, null, null);
var litter = await GetLitter(litterId);
Assert.Equal(JsonValueKind.Null, litter.GetProperty("fatherId").ValueKind);
Assert.Equal(JsonValueKind.Null, litter.GetProperty("motherId").ValueKind);
}
[Fact]
public async Task No_litter_and_no_parents_is_a_no_op()
{
var orphan = await CreateGerbil("Waise F", "female", "2025-08-01");
var result = await SetParents(orphan, null, null);
Assert.Equal(JsonValueKind.Null, result.GetProperty("litterId").ValueKind);
Assert.False(result.GetProperty("litterCreated").GetBoolean());
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
Assert.Equal(JsonValueKind.Null, gerbil.GetProperty("litterId").ValueKind);
}
[Fact]
public async Task Wrong_gender_parent_returns_400_InvalidParentGender()
{
var female = await CreateGerbil("Weibchen G", "female", "2024-06-01");
var pup = await CreateGerbil("Jungtier G", "male", "2025-09-01");
var resp = await _client.PutAsJsonAsync($"/gerbils/{pup}/parents",
new { fatherId = female, motherId = (string?)null });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
Assert.Contains("InvalidParentGender", await resp.Content.ReadAsStringAsync());
}
[Fact]
public async Task Self_as_parent_returns_400()
{
var g = await CreateGerbil("Selbstbezug H", "male", "2025-10-01");
var resp = await _client.PutAsJsonAsync($"/gerbils/{g}/parents",
new { fatherId = g, motherId = (string?)null });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Unknown_gerbil_returns_404()
{
var resp = await _client.PutAsJsonAsync($"/gerbils/{Guid.NewGuid()}/parents",
new { fatherId = (string?)null, motherId = (string?)null });
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
}

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
@@ -120,6 +120,17 @@ namespace GerbilManagerWebAPI.Dtos
bool? IsResident,
bool? IsCastrated);
/// <summary>QOL: Eltern direkt in der Tier-Akte setzen (PUT /gerbils/{id}/parents).
/// Das Datenmodell bleibt unverändert — geschrieben werden die Eltern des GEBURTSWURFS.</summary>
public record GerbilParentsInput(Guid? FatherId, Guid? MotherId);
/// <summary>Ergebnis von PUT /gerbils/{id}/parents: welcher Wurf die Eltern jetzt trägt, ob er
/// dafür neu angelegt (LitterCreated) bzw. ein bestehender verknüpft wurde (LitterAttached) und
/// wie viele Geschwister im selben Wurf die Änderung mitbetrifft.</summary>
public record GerbilParentsResult(
Guid? LitterId, string? LitterName, bool LitterCreated, bool LitterAttached,
int SiblingCount, Guid? FatherId, Guid? MotherId);
public record LitterInput(
string Name,
DateOnly? Date,

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
@@ -90,6 +90,65 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.NoContent();
});
// PUT /gerbils/{id}/parents — QOL (Wunsch der Züchterin): Eltern DIREKT in der
// Tier-Akte pflegen, ohne vorher den Wurf suchen zu müssen. Das Datenmodell bleibt
// unverändert — Eltern hängen weiterhin am GEBURTSWURF (Litter.FatherId/MotherId),
// dieser Endpoint schreibt nur genau dorthin:
// • Tier hat einen Wurf → dessen Eltern werden gesetzt. Das gilt zwangsläufig für
// ALLE Jungtiere des Wurfs; die UI warnt vorher mit SiblingCount.
// • Tier hat keinen Wurf → gibt es einen Wurf mit genau diesem Elternpaar UND
// demselben Datum wie das Geburtsdatum (= dieselbe Geburt), wird das Tier dort
// eingehängt (LitterAttached). Sonst wird ein Wurf angelegt (LitterCreated) mit
// ShowInChronicle=false (nicht in der Wurfchronik, nur zur Abstammung) und
// IsManual=true (überlebt den Re-Ingest).
group.MapPut("/{id:guid}/parents", async Task<Results<Ok<GerbilParentsResult>, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (
Guid id, GerbilParentsInput input, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
if (input.FatherId == id || input.MotherId == id)
return TypedResults.BadRequest("Ein Tier kann nicht sein eigener Elternteil sein.");
var genderErr = await LitterEndpoints.ValidateParents(db, input.FatherId, input.MotherId);
if (genderErr is not null) return TypedResults.BadRequest(genderErr);
var litter = g.LitterId is Guid lid
? await db.Litters.FirstOrDefaultAsync(l => l.Id == lid)
: null;
// Nichts zu tun: kein Wurf und keine Eltern angegeben.
if (litter is null && input.FatherId is null && input.MotherId is null)
return TypedResults.Ok(new GerbilParentsResult(null, null, false, false, 0, null, null));
var created = false;
var attached = false;
if (litter is null)
{
// Gleiches Elternpaar + gleiches Datum ⇒ dieselbe Geburt: an den bestehenden
// Wurf hängen (macht das Tier korrekt zum Geschwister), statt zu duplizieren.
if (g.DateOfBirth is DateOnly dob)
{
litter = await db.Litters.FirstOrDefaultAsync(l =>
l.FatherId == input.FatherId && l.MotherId == input.MotherId && l.Date == dob);
attached = litter is not null;
}
litter ??= NewParentLitter(g, await NameOfAsync(db, input.FatherId), await NameOfAsync(db, input.MotherId));
if (!attached)
{
db.Litters.Add(litter);
created = true;
}
g.LitterId = litter.Id;
}
litter.FatherId = input.FatherId;
litter.MotherId = input.MotherId;
await db.SaveChangesAsync();
var siblingCount = await db.Gerbils.CountAsync(x => x.LitterId == litter.Id && x.Id != id);
return TypedResults.Ok(new GerbilParentsResult(
litter.Id, litter.Name, created, attached, siblingCount, litter.FatherId, litter.MotherId));
});
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
@@ -110,6 +169,24 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
private static async Task<string?> NameOfAsync(ApplicationContext db, Guid? gerbilId) =>
gerbilId is Guid gid
? await db.Gerbils.AsNoTracking().Where(x => x.Id == gid).Select(x => x.Name).FirstOrDefaultAsync()
: null;
/// <summary>Träger-Wurf für die Eltern eines Tiers ohne Geburtswurf. Namensschema wie die
/// vom Import erzeugten Stammbaum-Würfe ("Wurf von X + Y"); ShowInChronicle=false, damit die
/// Wurfchronik nicht mit Hilfs-Würfen zuwächst; IsManual=true, damit der Re-Ingest ihn nicht wegräumt.</summary>
private static Litter NewParentLitter(Gerbil g, string? fatherName, string? motherName) => new()
{
Id = Guid.NewGuid(),
Name = $"Wurf von {fatherName ?? ""} + {motherName ?? ""}",
Date = g.DateOfBirth,
ShowInChronicle = false,
IsManual = true,
Notes = $"Automatisch angelegt, um die Eltern von {g.Name} zu tragen.",
};
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
@@ -65,7 +65,9 @@ namespace GerbilManagerWebAPI.Endpoints
}
// father must not be female; mother must not be male (unknown is allowed).
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
// internal: auch von PUT /gerbils/{id}/parents genutzt (QOL-Eltern-Editor), damit die
// Geschlechts-Regel für Eltern an genau EINER Stelle lebt.
internal static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
{
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;

View File

@@ -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

View File

@@ -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()

View File

@@ -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}`)
}

View File

@@ -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>
/**

View File

@@ -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}

View File

@@ -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.',