Compare commits
15 Commits
feature/wu
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| 55c298ad1f | |||
| e1e088f041 | |||
| 358e30db1f | |||
| 0b9b03eb2d | |||
| d4c9be23c8 | |||
| 162a0fafb1 | |||
| 2bbdfe2b71 |
143
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
143
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
|
||||||
|
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
|
||||||
|
/// send partial bodies — all other fields must be preserved unchanged.</summary>
|
||||||
|
public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
private static Guid ExtractId(string json) =>
|
||||||
|
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||||
|
|
||||||
|
private static string? GetStr(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString() : null;
|
||||||
|
|
||||||
|
private static string? GetNested(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
|
||||||
|
|
||||||
|
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||||
|
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
gender = "female",
|
||||||
|
genotype = "aa CC DD ee GG PP spsp rere",
|
||||||
|
originBreeder = "Zucht der Kleinen Chaoten",
|
||||||
|
isDeaf = false,
|
||||||
|
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||||
|
characterNote = "Liebling der Familie",
|
||||||
|
notes = "Eine Notiz",
|
||||||
|
externalRef = "ext-001",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForSale_toggle_does_not_wipe_other_fields()
|
||||||
|
{
|
||||||
|
// Arrange: create a fully-populated gerbil
|
||||||
|
var id = await CreateFullGerbil("ForSale-Tier");
|
||||||
|
|
||||||
|
// Act: send only { status: 'ForSale' } — exactly what the detail page does
|
||||||
|
var put = await _client.PutAsync($"/gerbils/{id}",
|
||||||
|
JsonContent.Create(new { status = "ForSale" }));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
// Assert: re-read and check all fields survive
|
||||||
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("ForSale", GetStr(json, "status"));
|
||||||
|
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||||
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
|
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||||
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CharacterSave_does_not_wipe_other_fields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = await CreateFullGerbil("Charakter-Tier");
|
||||||
|
|
||||||
|
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
|
||||||
|
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
characterTraits = new[] { "handzahm" },
|
||||||
|
characterNote = "Neue Notiz",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||||
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("Active", GetStr(json, "status"));
|
||||||
|
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||||
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
|
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||||
|
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||||
|
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditForm_omitting_import_fields_keeps_them()
|
||||||
|
{
|
||||||
|
// Arrange: gerbil with import-set fields
|
||||||
|
var id = await CreateFullGerbil("EditForm-Tier");
|
||||||
|
|
||||||
|
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
|
||||||
|
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name = "EditForm-Tier (umbenannt)",
|
||||||
|
gender = "female",
|
||||||
|
genotype = "aa CC DD ee GG PP spsp rere",
|
||||||
|
notes = "Aktualisierte Notiz",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
// Assert: name updated, import fields survive
|
||||||
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||||
|
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
|
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||||
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_with_minimal_body_uses_safe_defaults()
|
||||||
|
{
|
||||||
|
// A minimal POST (only name) must still produce a valid gerbil
|
||||||
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
|
||||||
|
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||||
|
Assert.Equal("Active", GetStr(json, "status"));
|
||||||
|
// characterTraits defaults to [] on create when not provided
|
||||||
|
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
// Request DTOs -----------------------------------------------------------
|
// Request DTOs -----------------------------------------------------------
|
||||||
|
|
||||||
public record GerbilInput(
|
public record GerbilInput(
|
||||||
string Name,
|
string? Name,
|
||||||
Gender Gender,
|
Gender? Gender,
|
||||||
GerbilStatus? Status,
|
GerbilStatus? Status,
|
||||||
Guid? LitterId,
|
Guid? LitterId,
|
||||||
Guid? OriginContactId,
|
Guid? OriginContactId,
|
||||||
|
|||||||
@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||||
Apply(g, input, isCreate: true);
|
Apply(g, input, isCreate: true);
|
||||||
db.Gerbils.Add(g);
|
db.Gerbils.Add(g);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /gerbils/{id}
|
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (g is null) return TypedResults.NotFound();
|
if (g is null) return TypedResults.NotFound();
|
||||||
g.Name = input.Name;
|
|
||||||
Apply(g, input, isCreate: false);
|
Apply(g, input, isCreate: false);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||||
|
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||||
|
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||||
{
|
{
|
||||||
g.Gender = i.Gender;
|
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||||
|
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||||
g.LitterId = i.LitterId;
|
g.LitterId = i.LitterId ?? g.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId;
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||||
g.EnclosureId = i.EnclosureId;
|
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||||
g.ColorVarietyId = i.ColorVarietyId;
|
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||||
g.DateOfBirth = i.DateOfBirth;
|
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||||
g.DateOfDeath = i.DateOfDeath;
|
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||||
g.CauseOfDeath = i.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||||
g.Genotype = i.Genotype;
|
g.Genotype = i.Genotype ?? g.Genotype;
|
||||||
g.Notes = i.Notes;
|
g.Notes = i.Notes ?? g.Notes;
|
||||||
g.ImportSource = i.ImportSource;
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef;
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||||
g.OriginBreeder = i.OriginBreeder;
|
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||||
g.CharacterNote = i.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
ResidencySummary? Residency = null);
|
ResidencySummary? Residency = null);
|
||||||
|
|
||||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
||||||
|
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
|
||||||
|
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
|
||||||
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
|
||||||
|
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
|
||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
|
|||||||
@@ -137,12 +137,14 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||||
var existingRows = await _db.Gerbils
|
var existingRows = await _db.Gerbils
|
||||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
|
||||||
var gidByNameDob = new Dictionary<string, Guid>();
|
var gidByNameDob = new Dictionary<string, Guid>();
|
||||||
foreach (var g in existingRows)
|
foreach (var g in existingRows)
|
||||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||||
|
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
|
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||||
|
|
||||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||||
var plan = new List<AnimalPlan>();
|
var plan = new List<AnimalPlan>();
|
||||||
@@ -169,7 +171,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||||
|
|
||||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
||||||
|
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||||
@@ -460,6 +463,51 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
|
||||||
|
// Counts already-imported animals where the current extract matched a DIFFERENT
|
||||||
|
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
|
||||||
|
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||||
|
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||||
|
|
||||||
|
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||||
|
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||||
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
|
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
||||||
|
int herkunftBackfilled = 0;
|
||||||
|
{
|
||||||
|
var needsHerkunft = await _db.Gerbils
|
||||||
|
.Where(g => g.OriginBreeder == null && g.IsResident)
|
||||||
|
.Select(g => new { g.Id, g.LitterId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var g in needsHerkunft)
|
||||||
|
{
|
||||||
|
herkunftBackfilled++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
// Prefer the OriginBreeder of an existing parent (father first, then mother).
|
||||||
|
string? derived = null;
|
||||||
|
if (g.LitterId is not null)
|
||||||
|
{
|
||||||
|
var parentIds = await _db.Litters
|
||||||
|
.Where(l => l.Id == g.LitterId)
|
||||||
|
.Select(l => new { l.FatherId, l.MotherId })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (parentIds?.FatherId is Guid fid)
|
||||||
|
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
|
||||||
|
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||||
|
if (derived is null && parentIds?.MotherId is Guid mid)
|
||||||
|
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
|
||||||
|
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||||
|
if (row is not null && row.OriginBreeder is null)
|
||||||
|
row.OriginBreeder = derived ?? DefaultClanBreeder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (littersWithoutDate > 0)
|
if (littersWithoutDate > 0)
|
||||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
@@ -469,6 +517,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (parentFksBackfilled > 0)
|
if (parentFksBackfilled > 0)
|
||||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
|
if (herkunftBackfilled > 0)
|
||||||
|
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||||
|
if (farbschlagWouldRebackfill > 0)
|
||||||
|
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
if (conflictsResolvedByDecision > 0)
|
||||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||||
@@ -484,7 +536,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||||
|
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||||
}
|
}
|
||||||
|
|
||||||
private T? Load<T>(string file)
|
private T? Load<T>(string file)
|
||||||
@@ -574,7 +627,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||||
private sealed record AnimalPlan(
|
private sealed record AnimalPlan(
|
||||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
|
||||||
|
Guid? CurrentColorVarietyId = null);
|
||||||
|
|
||||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
|
|||||||
test.describe('Becken', () => {
|
test.describe('Becken', () => {
|
||||||
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/becken')
|
await page.goto('/gehege')
|
||||||
await page.getByRole('link', { name: /Großbecken/ }).click()
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
||||||
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
|
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
|
||||||
@@ -18,7 +18,7 @@ test.describe('Becken', () => {
|
|||||||
|
|
||||||
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/becken')
|
await page.goto('/gehege')
|
||||||
await page.getByRole('link', { name: /Großbecken/ }).click()
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
acceptNextDialog(page)
|
acceptNextDialog(page)
|
||||||
await page.getByRole('button', { name: tb.delete.action }).click()
|
await page.getByRole('button', { name: tb.delete.action }).click()
|
||||||
|
|||||||
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* QA-REGRESSION-1: GEHEGE-RENAME Lock-in.
|
||||||
|
*
|
||||||
|
* Sichert das Verhalten von drei GEHEGE-RENAME-Änderungen ab, die leicht still
|
||||||
|
* brechen können (Redirects, Nav-Einträge, Landing-URL):
|
||||||
|
* (a) /becken/* → /gehege/* Redirect (bookmarks / externe Links)
|
||||||
|
* (b) Nav zeigt 'Gehege' + 🏜️, kein 'Becken', kein 'Start'-Eintrag
|
||||||
|
* (c) '/' (Index) landet auf /rennmaeuse (nicht mehr auf einer Startseite)
|
||||||
|
*
|
||||||
|
* Bereits abgedeckt (NICHT dupliziert):
|
||||||
|
* - gotoSection(de.nav.enclosures) navigiert zu Gehege-Heading: navigation.spec.ts:19-20
|
||||||
|
* - CRUD + 409 auf /gehege: becken-kontakte.spec.ts
|
||||||
|
* - Wurf-Link + Herkunft: tiere.spec.ts:101-128
|
||||||
|
*/
|
||||||
|
import { de, expect, test } from './fixtures'
|
||||||
|
|
||||||
|
// ── (a) /becken/* → /gehege/* Redirect ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: /becken leitet auf /gehege weiter (bookmark-safe)', async ({ page }) => {
|
||||||
|
await page.goto('/becken')
|
||||||
|
// React Router BeckenRedirect navigiert → /gehege; Gehege-Heading muss sichtbar sein
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/gehege$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: /becken/neu leitet auf /gehege/neu weiter (deep redirect)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/becken/neu')
|
||||||
|
// Neues-Gehege-Formular muss sichtbar sein, URL muss /gehege/neu sein
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.form?.createTitle ?? de.pages.becken.newButton })).toBeVisible({ timeout: 6_000 })
|
||||||
|
await expect(page).toHaveURL(/\/gehege\/neu$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── (b) Nav: 'Gehege' ✓, 'Becken' ✗, 'Start' ✗ ──────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: Nav hat "Gehege"-Eintrag mit Terrarium-Icon, kein "Becken"', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
|
|
||||||
|
// Gehege-Link soll vorhanden sein (via „Mehr"-Blatt auf Phone oder direkt auf Desktop)
|
||||||
|
// Wir öffnen „Mehr" um sekundäre Links sichtbar zu machen
|
||||||
|
const moreBtn = nav.getByRole('button', { name: de.nav.more })
|
||||||
|
if (await moreBtn.isVisible()) {
|
||||||
|
await moreBtn.click()
|
||||||
|
}
|
||||||
|
await expect(nav.getByRole('link', { name: de.nav.enclosures })).toBeVisible()
|
||||||
|
// "Becken" als separater Nav-Eintrag darf nicht mehr existieren
|
||||||
|
await expect(nav.getByRole('link', { name: 'Becken', exact: true })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: Nav hat keinen "Start"-Eintrag mehr (de.nav.home entfernt)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
// Warten bis die Tierliste geladen ist, dann Nav prüfen
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
|
// 'Start' (de.nav.home wurde entfernt) darf kein Nav-Link mehr sein
|
||||||
|
await expect(nav.getByRole('link', { name: 'Start', exact: true })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── (c) '/' landet auf /rennmaeuse ──────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: "/" leitet auf /rennmaeuse weiter — keine Startseite mehr', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/')
|
||||||
|
// URL muss /rennmaeuse sein (Navigate replace)
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||||
|
// Tierliste-Heading sichtbar — nicht die alte Startseite
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
// Alten Willkommen-Text darf es nicht mehr geben (de.pages.home wurde entfernt)
|
||||||
|
await expect(page.getByText('Willkommen')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: 404-Seite "Zur Startseite" landet weiterhin auf Tierliste', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/diese-seite-gibt-es-nicht')
|
||||||
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
|
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||||
|
// Nach Klick auf "Zur Startseite" → '/' → redirect → /rennmaeuse
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||||
|
})
|
||||||
@@ -149,6 +149,8 @@ export function seedDb(): MockDb {
|
|||||||
enclosureId: 'enc-leer',
|
enclosureId: 'enc-leer',
|
||||||
notes: 'neugieriger Entdecker',
|
notes: 'neugieriger Entdecker',
|
||||||
},
|
},
|
||||||
|
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
||||||
|
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
||||||
]
|
]
|
||||||
|
|
||||||
const litters: Litter[] = [
|
const litters: Litter[] = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { de, expect, gotoSection, test } from './fixtures'
|
|||||||
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
||||||
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
@@ -30,5 +30,5 @@ test('unbekannte Route zeigt die deutsche 404-Seite', async ({ page }) => {
|
|||||||
await page.goto('/diese-seite-gibt-es-nicht')
|
await page.goto('/diese-seite-gibt-es-nicht')
|
||||||
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||||
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
|||||||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||||
|
// Titel zeigt '(ohne Namen)'
|
||||||
|
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||||||
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
|
})
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF
|
|||||||
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
// 'nameless-stub' hat name='' → Platzhalter '(ohne Namen)' soll als Link sichtbar sein
|
||||||
|
await expect(page.getByRole('link', { name: de.pages.gerbils.nameless })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Namenlose Tiere zeigen Platzhalter im Detail-Header (UI-POLISH-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/nameless-stub')
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.nameless })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
@@ -136,3 +149,26 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
|
||||||
|
await page.goto('/rennmaeuse/fridolin/bearbeiten')
|
||||||
|
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
|
||||||
|
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
|
||||||
|
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
// Standard: Unbekannt (null → value='')
|
||||||
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
|
||||||
|
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||||
import AppShell from './components/AppShell'
|
import AppShell from './components/AppShell'
|
||||||
import BeckenPage from './pages/BeckenPage'
|
import BeckenPage from './pages/BeckenPage'
|
||||||
import BeckenDetailPage from './pages/BeckenDetailPage'
|
import BeckenDetailPage from './pages/BeckenDetailPage'
|
||||||
@@ -8,7 +8,6 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
|
|||||||
import GerbilFormPage from './pages/GerbilFormPage'
|
import GerbilFormPage from './pages/GerbilFormPage'
|
||||||
import GenetikPage from './pages/GenetikPage'
|
import GenetikPage from './pages/GenetikPage'
|
||||||
import AbgabePage from './pages/AbgabePage'
|
import AbgabePage from './pages/AbgabePage'
|
||||||
import HomePage from './pages/HomePage'
|
|
||||||
import KontaktePage from './pages/KontaktePage'
|
import KontaktePage from './pages/KontaktePage'
|
||||||
import KontaktDetailPage from './pages/KontaktDetailPage'
|
import KontaktDetailPage from './pages/KontaktDetailPage'
|
||||||
import KontaktFormPage from './pages/KontaktFormPage'
|
import KontaktFormPage from './pages/KontaktFormPage'
|
||||||
@@ -28,12 +27,17 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
|||||||
import AnfragenPage from './pages/AnfragenPage'
|
import AnfragenPage from './pages/AnfragenPage'
|
||||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||||
|
|
||||||
|
function BeckenRedirect() {
|
||||||
|
const { '*': splat } = useParams()
|
||||||
|
return <Navigate to={`/gehege${splat ? `/${splat}` : ''}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<Navigate to="/rennmaeuse" replace />} />
|
||||||
<Route path="rennmaeuse">
|
<Route path="rennmaeuse">
|
||||||
<Route index element={<GerbilsPage />} />
|
<Route index element={<GerbilsPage />} />
|
||||||
<Route path="neu" element={<GerbilFormPage />} />
|
<Route path="neu" element={<GerbilFormPage />} />
|
||||||
@@ -48,7 +52,9 @@ export default function App() {
|
|||||||
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
|
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
|
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
|
||||||
<Route path="becken">
|
{/* GEHEGE-RENAME: /becken → /gehege; redirect preserves bookmarks */}
|
||||||
|
<Route path="becken/*" element={<BeckenRedirect />} />
|
||||||
|
<Route path="gehege">
|
||||||
<Route index element={<BeckenPage />} />
|
<Route index element={<BeckenPage />} />
|
||||||
<Route path="neu" element={<BeckenFormPage />} />
|
<Route path="neu" element={<BeckenFormPage />} />
|
||||||
<Route path=":id" element={<BeckenDetailPage />} />
|
<Route path=":id" element={<BeckenDetailPage />} />
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface Gerbil {
|
|||||||
* as resident (backend default true).
|
* as resident (backend default true).
|
||||||
*/
|
*/
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -76,6 +78,7 @@ export interface CreateGerbil {
|
|||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
const nameless = de.pages.gerbils.nameless
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import { andFilter, condition } from '../api/gridify'
|
import { andFilter, condition } from '../api/gridify'
|
||||||
import type { Gender, Gerbil, Paged } from '../api/types'
|
import type { Gender, Gerbil, Paged } from '../api/types'
|
||||||
@@ -62,7 +64,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{g.name}
|
{g.name || nameless}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ interface NavItem {
|
|||||||
// Primary items are always visible in the phone bottom bar; secondary items
|
// Primary items are always visible in the phone bottom bar; secondary items
|
||||||
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
|
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
|
||||||
const PRIMARY: NavItem[] = [
|
const PRIMARY: NavItem[] = [
|
||||||
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
|
|
||||||
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
||||||
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
||||||
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
||||||
]
|
]
|
||||||
const SECONDARY: NavItem[] = [
|
const SECONDARY: NavItem[] = [
|
||||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
|
||||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||||
// INBOX-1: Anfragen-Posteingang
|
// INBOX-1: Anfragen-Posteingang
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function BeckenDetailPage() {
|
|||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
const result = await removal.run()
|
const result = await removal.run()
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
navigate('/becken')
|
navigate('/gehege')
|
||||||
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
||||||
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
||||||
setDeleteError(t.delete.conflict)
|
setDeleteError(t.delete.conflict)
|
||||||
@@ -58,7 +58,7 @@ export default function BeckenDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
||||||
<Link to="/becken" className="btn">
|
<Link to="/gehege" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
@@ -81,7 +81,7 @@ export default function BeckenDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/becken/${e.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/gehege/${e.id}/bearbeiten`} className="btn btn--primary">
|
||||||
{t.detail.edit}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +92,7 @@ export default function BeckenDetailPage() {
|
|||||||
>
|
>
|
||||||
{t.delete.action}
|
{t.delete.action}
|
||||||
</button>
|
</button>
|
||||||
<Link to="/becken" className="btn">
|
<Link to="/gehege" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function BeckenFormPage() {
|
|||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||||
if (result.ok) navigate(`/becken/${result.value.id}`)
|
if (result.ok) navigate(`/gehege/${result.value.id}`)
|
||||||
// On failure mutation.error drives the inline alert; run() never throws.
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function BeckenFormPage() {
|
|||||||
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
||||||
{mutation.pending ? t.form.saving : t.form.save}
|
{mutation.pending ? t.form.saving : t.form.save}
|
||||||
</button>
|
</button>
|
||||||
<Link to={isEdit && id ? `/becken/${id}` : '/becken'} className="btn">
|
<Link to={isEdit && id ? `/gehege/${id}` : '/gehege'} className="btn">
|
||||||
{t.form.cancel}
|
{t.form.cancel}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function BeckenPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to="/becken/neu" className="btn btn--primary">
|
<Link to="/gehege/neu" className="btn btn--primary">
|
||||||
+ {t.newButton}
|
+ {t.newButton}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
@@ -72,7 +72,7 @@ export default function BeckenPage() {
|
|||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{items.map((e) => (
|
{items.map((e) => (
|
||||||
<li key={e.id}>
|
<li key={e.id}>
|
||||||
<Link to={`/becken/${e.id}`} className="gerbil-card">
|
<Link to={`/gehege/${e.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{e.name}</span>
|
<span className="gerbil-card__name">{e.name}</span>
|
||||||
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
|
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function GerbilDetailPage() {
|
|||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
<GerbilProfilePhoto gerbilId={g.id} />
|
||||||
<h2>{g.name}</h2>
|
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||||
@@ -153,7 +153,7 @@ export default function GerbilDetailPage() {
|
|||||||
{g.status === 'Deceased' && (
|
{g.status === 'Deceased' && (
|
||||||
<>
|
<>
|
||||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
||||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath} />
|
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{g.status === 'GivenAway' && (
|
{g.status === 'GivenAway' && (
|
||||||
@@ -190,7 +190,7 @@ export default function GerbilDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||||
<Row label={t.fields.notes} value={g.notes} />
|
<Row label={t.fields.notes} value={g.notes || null} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h3>{t.detail.genetics}</h3>
|
<h3>{t.detail.genetics}</h3>
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ interface FormState {
|
|||||||
enclosureId: string
|
enclosureId: string
|
||||||
litterId: string
|
litterId: string
|
||||||
originContactId: string
|
originContactId: string
|
||||||
|
originBreeder: string
|
||||||
receiverContactId: string
|
receiverContactId: string
|
||||||
genotype: string
|
genotype: string
|
||||||
notes: string
|
notes: string
|
||||||
isResident: boolean
|
isResident: boolean
|
||||||
|
isDeaf: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -39,10 +41,12 @@ const EMPTY: FormState = {
|
|||||||
enclosureId: '',
|
enclosureId: '',
|
||||||
litterId: '',
|
litterId: '',
|
||||||
originContactId: '',
|
originContactId: '',
|
||||||
|
originBreeder: '',
|
||||||
receiverContactId: '',
|
receiverContactId: '',
|
||||||
genotype: '',
|
genotype: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isResident: true,
|
isResident: true,
|
||||||
|
isDeaf: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -57,10 +61,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: string | null
|
enclosureId: string | null
|
||||||
litterId: string | null
|
litterId: string | null
|
||||||
originContactId: string | null
|
originContactId: string | null
|
||||||
|
originBreeder?: string | null
|
||||||
receiverContactId: string | null
|
receiverContactId: string | null
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
isResident?: boolean | null
|
isResident?: boolean | null
|
||||||
|
isDeaf?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
@@ -74,10 +80,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: g.enclosureId ?? '',
|
enclosureId: g.enclosureId ?? '',
|
||||||
litterId: g.litterId ?? '',
|
litterId: g.litterId ?? '',
|
||||||
originContactId: g.originContactId ?? '',
|
originContactId: g.originContactId ?? '',
|
||||||
|
originBreeder: g.originBreeder ?? '',
|
||||||
receiverContactId: g.receiverContactId ?? '',
|
receiverContactId: g.receiverContactId ?? '',
|
||||||
genotype: g.genotype ?? '',
|
genotype: g.genotype ?? '',
|
||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
isResident: g.isResident ?? true,
|
isResident: g.isResident ?? true,
|
||||||
|
isDeaf: g.isDeaf ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,10 +175,12 @@ export default function GerbilFormPage() {
|
|||||||
enclosureId: nn(form.enclosureId),
|
enclosureId: nn(form.enclosureId),
|
||||||
litterId: nn(form.litterId),
|
litterId: nn(form.litterId),
|
||||||
originContactId: nn(form.originContactId),
|
originContactId: nn(form.originContactId),
|
||||||
|
originBreeder: nn(form.originBreeder),
|
||||||
receiverContactId: nn(form.receiverContactId),
|
receiverContactId: nn(form.receiverContactId),
|
||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
isResident: form.isResident,
|
isResident: form.isResident,
|
||||||
|
isDeaf: form.isDeaf,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
@@ -326,6 +336,16 @@ export default function GerbilFormPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.originBreeder}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.originBreeder}
|
||||||
|
placeholder="z. B. Zucht der Kleinen Chaoten"
|
||||||
|
onChange={(e) => set('originBreeder', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.receiver}</span>
|
<span>{t.fields.receiver}</span>
|
||||||
<select
|
<select
|
||||||
@@ -360,6 +380,21 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.isDeaf}</span>
|
||||||
|
<select
|
||||||
|
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
set('isDeaf', v === '' ? null : v === 'true')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t.form.isDeafUnknown}</option>
|
||||||
|
<option value="true">{t.form.isDeafYes}</option>
|
||||||
|
<option value="false">{t.form.isDeafNo}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="field field--check" title={t.form.isResidentHint}>
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
<span>{t.form.isResidentLabel}</span>
|
<span>{t.form.isResidentLabel}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -256,11 +256,11 @@ export default function GerbilsPage() {
|
|||||||
className="gerbil-row__check"
|
className="gerbil-row__check"
|
||||||
checked={selected.has(g.id)}
|
checked={selected.has(g.id)}
|
||||||
onChange={() => toggleSelect(g.id)}
|
onChange={() => toggleSelect(g.id)}
|
||||||
aria-label={g.name}
|
aria-label={g.name || t.nameless}
|
||||||
/>
|
/>
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">
|
<span className="gerbil-card__name">
|
||||||
{g.name}
|
{g.name || t.nameless}
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={t.externalTitle}>
|
<span className="badge badge--external" title={t.externalTitle}>
|
||||||
{t.externalBadge}
|
{t.externalBadge}
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ const sections: Section[] = [
|
|||||||
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>„{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
|
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>„{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
|
||||||
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
|
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Becken anlegen (wo leben deine Tiere?)</li>
|
<li>Gehege anlegen (wo leben deine Tiere?)</li>
|
||||||
<li>Rennmäuse anlegen mit Foto</li>
|
<li>Rennmäuse anlegen mit Foto</li>
|
||||||
<li>Würfe eintragen</li>
|
<li>Würfe eintragen</li>
|
||||||
<li>Genetik erkunden</li>
|
<li>Genetik erkunden</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="hilfe-hint">Tipp: Lege zuerst die Becken an — dann kannst du beim Erfassen der Tiere direkt das richtige Becken auswählen.</p>
|
<p className="hilfe-hint">Tipp: Lege zuerst die Gehege an — dann kannst du beim Erfassen der Tiere direkt das richtige Gehege auswählen.</p>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -53,7 +53,7 @@ const sections: Section[] = [
|
|||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>„{g.newButton}"</strong> legst du ein neues Tier an.</p>
|
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>„{g.newButton}"</strong> legst du ein neues Tier an.</p>
|
||||||
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>„{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Becken und Herkunft eintragen.</p>
|
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>„{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Gehege und Herkunft eintragen.</p>
|
||||||
<p>Auf der Detailseite findest du drei Tabs:</p>
|
<p>Auf der Detailseite findest du drei Tabs:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>„{g.detail.tabs.photos}"</strong> — Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
|
<li><strong>„{g.detail.tabs.photos}"</strong> — Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
|
||||||
@@ -69,9 +69,9 @@ const sections: Section[] = [
|
|||||||
title: t.sections.becken,
|
title: t.sections.becken,
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<p>Unter <strong>„{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Becken an (nur Name ist Pflicht).</p>
|
<p>Unter <strong>„{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Gehege an (nur Name ist Pflicht).</p>
|
||||||
<p>Die Detailseite zeigt dir die aktuellen <strong>„{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
|
<p>Die Detailseite zeigt dir die aktuellen <strong>„{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
|
||||||
<p>Ein Becken lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
|
<p>Ein Gehege lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
|
||||||
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
|
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { de } from '../strings/de'
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2>{de.pages.home.title}</h2>
|
|
||||||
<p>{de.pages.home.intro}</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -276,7 +276,7 @@ export default function StammbaumPage() {
|
|||||||
<>
|
<>
|
||||||
<section className="page stammbaum-screen">
|
<section className="page stammbaum-screen">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<h2>{t.titleFor(root.gerbil.name)}</h2>
|
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||||
{t.backToAnimal}
|
{t.backToAnimal}
|
||||||
@@ -377,7 +377,7 @@ function PedigreeCard({
|
|||||||
<div className="pedigree-card__body">
|
<div className="pedigree-card__body">
|
||||||
<div className="pedigree-card__name">
|
<div className="pedigree-card__name">
|
||||||
<SexIcon gender={g.gender} />
|
<SexIcon gender={g.gender} />
|
||||||
<span className="pedigree-card__nametext">{g.name}</span>
|
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
||||||
</div>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
@@ -439,7 +439,7 @@ function PrintPedigree({
|
|||||||
return (
|
return (
|
||||||
<div className="stammbaum-print">
|
<div className="stammbaum-print">
|
||||||
<header className="stammbaum-print__head">
|
<header className="stammbaum-print__head">
|
||||||
<h1>{t.titleFor(root.gerbil.name)}</h1>
|
<h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
|
||||||
<p className="stammbaum-print__meta">
|
<p className="stammbaum-print__meta">
|
||||||
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
||||||
</p>
|
</p>
|
||||||
@@ -494,7 +494,7 @@ function PrintCell({
|
|||||||
<div className={base} style={style}>
|
<div className={base} style={style}>
|
||||||
<div className="stammbaum-print__name">
|
<div className="stammbaum-print__name">
|
||||||
{sexSymbol}
|
{sexSymbol}
|
||||||
{g.name}
|
{g.name || de.pages.gerbils.nameless}
|
||||||
</div>
|
</div>
|
||||||
{g.dateOfBirth && (
|
{g.dateOfBirth && (
|
||||||
<div className="stammbaum-print__sub">
|
<div className="stammbaum-print__sub">
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
|
|||||||
checked={selectedIds.has(g.id)}
|
checked={selectedIds.has(g.id)}
|
||||||
onChange={() => toggleAnimal(g.id)}
|
onChange={() => toggleAnimal(g.id)}
|
||||||
/>
|
/>
|
||||||
<span className="wizard-pick__name">{g.name}</span>
|
<span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
|
||||||
<span className="wizard-pick__meta">
|
<span className="wizard-pick__meta">
|
||||||
{[
|
{[
|
||||||
genderLabel(g.gender),
|
genderLabel(g.gender),
|
||||||
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.animals}</dt>
|
<dt>{de.pages.vertraege.fields.animals}</dt>
|
||||||
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
|
<dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.price}</dt>
|
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ export default function WurfFormPage() {
|
|||||||
name: l.name,
|
name: l.name,
|
||||||
date: l.date ?? '',
|
date: l.date ?? '',
|
||||||
fatherId: l.fatherId ?? '',
|
fatherId: l.fatherId ?? '',
|
||||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
|
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
|
||||||
motherId: l.motherId ?? '',
|
motherId: l.motherId ?? '',
|
||||||
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
|
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
|
||||||
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||||
notes: l.notes ?? '',
|
notes: l.notes ?? '',
|
||||||
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="male"
|
gender="male"
|
||||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
|
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="female"
|
gender="female"
|
||||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
|
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ export const de = {
|
|||||||
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
|
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Start',
|
|
||||||
gerbils: 'Rennmäuse',
|
gerbils: 'Rennmäuse',
|
||||||
litters: 'Würfe',
|
litters: 'Würfe',
|
||||||
breeders: 'Züchter',
|
breeders: 'Züchter',
|
||||||
genetics: 'Genetik',
|
genetics: 'Genetik',
|
||||||
// FEAT-2 (Oscar): Becken + Kontakte
|
// FEAT-2 (Oscar): Becken + Kontakte (GEHEGE-RENAME: Becken → Gehege)
|
||||||
enclosures: 'Becken',
|
enclosures: 'Gehege',
|
||||||
contacts: 'Kontakte',
|
contacts: 'Kontakte',
|
||||||
// FEAT-7 (Kelly): Statistik
|
// FEAT-7 (Kelly): Statistik
|
||||||
statistics: 'Statistik',
|
statistics: 'Statistik',
|
||||||
@@ -37,13 +36,9 @@ export const de = {
|
|||||||
website: 'Webseite',
|
website: 'Webseite',
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
home: {
|
|
||||||
title: 'Willkommen',
|
|
||||||
intro:
|
|
||||||
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
|
|
||||||
},
|
|
||||||
gerbils: {
|
gerbils: {
|
||||||
title: 'Rennmäuse',
|
title: 'Rennmäuse',
|
||||||
|
nameless: '(ohne Namen)',
|
||||||
// Liste
|
// Liste
|
||||||
newButton: 'Neue Rennmaus',
|
newButton: 'Neue Rennmaus',
|
||||||
empty: 'Keine Rennmäuse gefunden.',
|
empty: 'Keine Rennmäuse gefunden.',
|
||||||
@@ -82,9 +77,11 @@ export const de = {
|
|||||||
causeOfDeath: 'Todesursache',
|
causeOfDeath: 'Todesursache',
|
||||||
goHomeDate: 'Abgabedatum',
|
goHomeDate: 'Abgabedatum',
|
||||||
litter: 'Wurf',
|
litter: 'Wurf',
|
||||||
enclosure: 'Becken',
|
enclosure: 'Gehege',
|
||||||
colorVariety: 'Farbschlag',
|
colorVariety: 'Farbschlag',
|
||||||
origin: 'Herkunft',
|
origin: 'Herkunft',
|
||||||
|
originBreeder: 'Herkunft (Freitext)',
|
||||||
|
isDeaf: 'Gehörlos',
|
||||||
receiver: 'Abnehmer',
|
receiver: 'Abnehmer',
|
||||||
genotype: 'Genotyp',
|
genotype: 'Genotyp',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
@@ -119,6 +116,9 @@ export const de = {
|
|||||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
isResidentLabel: 'Gehört zum eigenen Bestand',
|
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||||
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||||
|
isDeafUnknown: 'Unbekannt',
|
||||||
|
isDeafYes: 'Ja',
|
||||||
|
isDeafNo: 'Nein',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -253,8 +253,8 @@ export const de = {
|
|||||||
markMultiAction: 'Ausgewählte zur Abgabe stellen',
|
markMultiAction: 'Ausgewählte zur Abgabe stellen',
|
||||||
grouping: {
|
grouping: {
|
||||||
title: 'Gruppen',
|
title: 'Gruppen',
|
||||||
byEnclosure: 'Nach Becken vorgeschlagen',
|
byEnclosure: 'Nach Gehege vorgeschlagen',
|
||||||
ungrouped: 'Ohne Becken',
|
ungrouped: 'Ohne Gehege',
|
||||||
moveTo: 'Verschieben nach',
|
moveTo: 'Verschieben nach',
|
||||||
newGroup: 'Neue Gruppe',
|
newGroup: 'Neue Gruppe',
|
||||||
groupLabel: 'Gruppe',
|
groupLabel: 'Gruppe',
|
||||||
@@ -298,12 +298,12 @@ export const de = {
|
|||||||
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
|
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-2 (Oscar): Becken (Enclosures) ──
|
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
|
||||||
becken: {
|
becken: {
|
||||||
title: 'Becken',
|
title: 'Gehege',
|
||||||
newButton: 'Neues Becken',
|
newButton: 'Neues Gehege',
|
||||||
empty: 'Keine Becken gefunden.',
|
empty: 'Keine Gehege gefunden.',
|
||||||
countLabel: 'Becken',
|
countLabel: 'Gehege',
|
||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
fields: {
|
fields: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -311,18 +311,18 @@ export const de = {
|
|||||||
},
|
},
|
||||||
occupancy: {
|
occupancy: {
|
||||||
title: 'Bewohner',
|
title: 'Bewohner',
|
||||||
empty: 'Dieses Becken ist derzeit leer.',
|
empty: 'Dieses Gehege ist derzeit leer.',
|
||||||
countOne: 'Tier',
|
countOne: 'Tier',
|
||||||
countMany: 'Tiere',
|
countMany: 'Tiere',
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
back: 'Zurück zur Liste',
|
back: 'Zurück zur Liste',
|
||||||
notFound: 'Dieses Becken wurde nicht gefunden.',
|
notFound: 'Dieses Gehege wurde nicht gefunden.',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
createTitle: 'Neues Becken anlegen',
|
createTitle: 'Neues Gehege anlegen',
|
||||||
editTitle: 'Becken bearbeiten',
|
editTitle: 'Gehege bearbeiten',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -332,9 +332,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
action: 'Löschen',
|
action: 'Löschen',
|
||||||
confirmMessage: 'Soll dieses Becken wirklich gelöscht werden?',
|
confirmMessage: 'Soll dieses Gehege wirklich gelöscht werden?',
|
||||||
conflict:
|
conflict:
|
||||||
'Becken ist nicht leer — bitte zuerst alle Tiere in ein anderes Becken umsetzen.',
|
'Gehege ist nicht leer — bitte zuerst alle Tiere in ein anderes Gehege umsetzen.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
|
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
|
||||||
@@ -731,7 +731,7 @@ export const de = {
|
|||||||
sections: {
|
sections: {
|
||||||
ersteSchritte: 'Erste Schritte',
|
ersteSchritte: 'Erste Schritte',
|
||||||
tiere: 'Rennmäuse anlegen & Fotos',
|
tiere: 'Rennmäuse anlegen & Fotos',
|
||||||
becken: 'Becken',
|
becken: 'Gehege',
|
||||||
wuerfe: 'Würfe & Jungtiere',
|
wuerfe: 'Würfe & Jungtiere',
|
||||||
genetik: 'Probeverpaarung & Genetik',
|
genetik: 'Probeverpaarung & Genetik',
|
||||||
stammbaum: 'Stammbaum',
|
stammbaum: 'Stammbaum',
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ def norm_zucht(z):
|
|||||||
if not z:
|
if not z:
|
||||||
return ""
|
return ""
|
||||||
n = z.lower()
|
n = z.lower()
|
||||||
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
|
# TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
|
||||||
|
# (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b.
|
||||||
|
n = re.sub(r"\bv\.\s?d\.", " ", n)
|
||||||
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
||||||
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
||||||
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
||||||
@@ -137,6 +139,15 @@ def norm_zucht(z):
|
|||||||
return ZUCHT_ALIASES.get(key, key)
|
return ZUCHT_ALIASES.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_clan_zucht(z):
|
||||||
|
"""True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery.
|
||||||
|
|
||||||
|
Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten',
|
||||||
|
'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc.
|
||||||
|
"""
|
||||||
|
return norm_zucht(z) == "kleinechaote"
|
||||||
|
|
||||||
|
|
||||||
def canon_pair(raw):
|
def canon_pair(raw):
|
||||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||||
name, zucht = split_name_zucht(raw)
|
name, zucht = split_name_zucht(raw)
|
||||||
|
|||||||
@@ -280,6 +280,33 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W
|
|||||||
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||||
|
|
||||||
|
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
||||||
|
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
||||||
|
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
||||||
|
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
|
||||||
|
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
|
||||||
|
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
|
||||||
|
e.is_clan_zucht("kleinen Chaoten"))
|
||||||
|
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
|
||||||
|
e.is_clan_zucht("v.d. Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
|
||||||
|
e.is_clan_zucht("ZdkC"))
|
||||||
|
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
|
||||||
|
e.is_clan_zucht("von den Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: empty string -> NOT clan",
|
||||||
|
not e.is_clan_zucht(""))
|
||||||
|
check("KC-matcher: 'Black Forest' -> NOT clan",
|
||||||
|
not e.is_clan_zucht("Black Forest"))
|
||||||
|
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
|
||||||
|
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
|
||||||
|
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
|
||||||
|
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
|
||||||
|
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
|
||||||
|
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
|
||||||
|
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
|
||||||
|
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print(f"\n{failed} test(s) FAILED")
|
print(f"\n{failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user