diff --git a/CLAUDE.md b/CLAUDE.md
index 9cb4811..87783c6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/GerbilManager.Tests/GerbilParentsTests.cs b/GerbilManager.Tests/GerbilParentsTests.cs
new file mode 100644
index 0000000..f26d737
--- /dev/null
+++ b/GerbilManager.Tests/GerbilParentsTests.cs
@@ -0,0 +1,188 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace GerbilManager.Tests;
+
+///
+/// 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.
+///
+public class GerbilParentsTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ public GerbilParentsTests(ApiFactory factory) => _client = factory.CreateClient();
+
+ private async Task 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 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 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 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);
+ }
+}
diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs
index 9ba4702..af1214c 100644
--- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs
+++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs
@@ -1,4 +1,4 @@
-using GerbilManagerWebAPI.Models;
+using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
@@ -120,6 +120,17 @@ namespace GerbilManagerWebAPI.Dtos
bool? IsResident,
bool? IsCastrated);
+ /// QOL: Eltern direkt in der Tier-Akte setzen (PUT /gerbils/{id}/parents).
+ /// Das Datenmodell bleibt unverändert — geschrieben werden die Eltern des GEBURTSWURFS.
+ public record GerbilParentsInput(Guid? FatherId, Guid? MotherId);
+
+ /// 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.
+ public record GerbilParentsResult(
+ Guid? LitterId, string? LitterName, bool LitterCreated, bool LitterAttached,
+ int SiblingCount, Guid? FatherId, Guid? MotherId);
+
public record LitterInput(
string Name,
DateOnly? Date,
diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
index 51d29d1..d8fbc02 100644
--- a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
+++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
@@ -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, NotFound, BadRequest, BadRequest>> (
+ 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>> (Guid id, ApplicationContext db) =>
{
@@ -110,6 +169,24 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
+ private static async Task NameOfAsync(ApplicationContext db, Guid? gerbilId) =>
+ gerbilId is Guid gid
+ ? await db.Gerbils.AsNoTracking().Where(x => x.Id == gid).Select(x => x.Name).FirstOrDefaultAsync()
+ : null;
+
+ /// 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.
+ 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
diff --git a/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs b/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
index b8b1262..a275965 100644
--- a/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
+++ b/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
@@ -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 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 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;
diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts
index 952df62..fd5c764 100644
--- a/gerbil-manager-web/e2e/mock-api.ts
+++ b/gerbil-manager-web/e2e/mock-api.ts
@@ -936,6 +936,79 @@ export async function installMockApi(page: Page): Promise {
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: / und //
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined
diff --git a/gerbil-manager-web/e2e/tiere.spec.ts b/gerbil-manager-web/e2e/tiere.spec.ts
index a9939f7..1ede14f 100644
--- a/gerbil-manager-web/e2e/tiere.spec.ts
+++ b/gerbil-manager-web/e2e/tiere.spec.ts
@@ -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()
diff --git a/gerbil-manager-web/src/api/gerbils.ts b/gerbil-manager-web/src/api/gerbils.ts
index f43ba0a..0a556f2 100644
--- a/gerbil-manager-web/src/api/gerbils.ts
+++ b/gerbil-manager-web/src/api/gerbils.ts
@@ -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> {
return api.get>(`${resources.gerbils}${toQueryString(query)}`)
@@ -24,6 +31,18 @@ export function updateGerbil(id: string, body: UpdateGerbil): Promise {
return api.put(`${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 {
+ return api.put(`${resources.gerbils}/${id}/parents`, body)
+}
+
export function deleteGerbil(id: string): Promise {
return api.delete(`${resources.gerbils}/${id}`)
}
diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts
index f49d255..b2f0143 100644
--- a/gerbil-manager-web/src/api/types.ts
+++ b/gerbil-manager-web/src/api/types.ts
@@ -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
/**
diff --git a/gerbil-manager-web/src/pages/GerbilFormPage.tsx b/gerbil-manager-web/src/pages/GerbilFormPage.tsx
index b0cf8c7..c115fdb 100644
--- a/gerbil-manager-web/src/pages/GerbilFormPage.tsx
+++ b/gerbil-manager-web/src/pages/GerbilFormPage.tsx
@@ -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>>({})
const [initializedFor, setInitializedFor] = useState(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(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 = (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> = {}
@@ -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
{de.common.loading}
@@ -503,17 +575,82 @@ export default function GerbilFormPage() {
)}
-
+ {/* 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. */}
+
+
{t.form.parentsSectionTitle}
+
+
+
+ {/* Vater/Mutter untereinander — auf dem Handy ist nebeneinander zu eng
+ (die Wurf-Seite stapelt die beiden Picker genauso). */}
+
+
+
+
+ {t.form.parentsHint}
+
+ {parentsChanged && !form.litterId && (
+
+ {t.form.parentsWillCreateLitter}
+
+ )}
+ {parentsChanged && selectedLitter && siblingCount > 0 && (
+