From 54e873c11549dc0b60515419a51b1ebe451affb2 Mon Sep 17 00:00:00 2001 From: Gulum Date: Wed, 19 Aug 2026 20:43:28 +0200 Subject: [PATCH] feat(tiere): Eltern direkt in der Tier-Akte bearbeiten (QOL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 15 ++ GerbilManager.Tests/GerbilParentsTests.cs | 188 ++++++++++++++++++ GerbilManagerWebAPI/Dtos/ApiDtos.cs | 13 +- .../Endpoints/GerbilEndpoints.cs | 79 +++++++- .../Endpoints/LitterEndpoints.cs | 6 +- gerbil-manager-web/e2e/mock-api.ts | 73 +++++++ gerbil-manager-web/e2e/tiere.spec.ts | 62 +++++- gerbil-manager-web/src/api/gerbils.ts | 21 +- gerbil-manager-web/src/api/types.ts | 24 +++ .../src/pages/GerbilFormPage.tsx | 183 +++++++++++++++-- gerbil-manager-web/src/strings/de.ts | 18 ++ 11 files changed, 653 insertions(+), 29 deletions(-) create mode 100644 GerbilManager.Tests/GerbilParentsTests.cs 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.detail.parentLittersRoleVater} + {fatherId ? ( +

+ {father.data?.name || de.pages.gerbils.nameless} + +

+ ) : ( + setParent('father', g.id)} /> + )} +
+ +
+ {t.detail.parentLittersRoleMutter} + {motherId ? ( +

+ {mother.data?.name || de.pages.gerbils.nameless} + +

+ ) : ( + setParent('mother', g.id)} /> + )} +
+ + {t.form.parentsHint} + + {parentsChanged && !form.litterId && ( + + {t.form.parentsWillCreateLitter} + + )} + {parentsChanged && selectedLitter && siblingCount > 0 && ( +
+ {t.form.parentsSiblingWarning(selectedLitter.name, siblingCount)} +
+ )} +
@@ -555,8 +692,12 @@ export default function GerbilFormPage() { {mutation.error &&
{mutation.error}
}
- {t.form.cancel} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index e641344..3c57238 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -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.',