FEAT: Implement deceased/givenaway enclosure visibility rules, preserve external clan name, and hide receiver fields for deceased gerbils

This commit is contained in:
2026-06-13 01:40:19 +02:00
parent 5732398e60
commit 396d8f05d8
53 changed files with 7431 additions and 274 deletions

View File

@@ -122,7 +122,7 @@ namespace GerbilManager.Tests
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status); Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate); Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder); Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident); Assert.False(gerbil.IsResident);
Assert.Equal("docx", gerbil.ImportSource); Assert.Equal("docx", gerbil.ImportSource);
Assert.StartsWith("docx-", gerbil.ExternalRef); Assert.StartsWith("docx-", gerbil.ExternalRef);

View File

@@ -36,7 +36,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
name, name,
gender = "female", gender = "female",
genotype = "aa CC DD ee GG PP spsp rere", genotype = "aa CC DD ee GG PP spsp rere",
originBreeder = "Zucht der Kleinen Chaoten", originBreeder = "Zucht der kleinen Chaoten",
isDeaf = false, isDeaf = false,
characterTraits = new[] { "neugierig", "zutraulich" }, characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie", characterNote = "Liebling der Familie",
@@ -67,7 +67,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("ForSale-Tier", GetStr(json, "name")); Assert.Equal("ForSale-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender")); Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes")); Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? ""); Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
@@ -95,7 +95,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("Charakter-Tier", GetStr(json, "name")); Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender")); Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes")); Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? ""); Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
@@ -123,7 +123,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name")); Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes")); Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? ""); Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote")); Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));

View File

@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath, string? CauseOfDeath,
DateOnly? GoHomeDate, DateOnly? GoHomeDate,
string? Genotype, string? Genotype,
string? SpottingType,
string? Notes, string? Notes,
string? ImportSource, string? ImportSource,
string? ExternalRef, string? ExternalRef,
@@ -30,7 +31,8 @@ namespace GerbilManagerWebAPI.Dtos
string? CharacterNote, string? CharacterNote,
bool? IsDeaf, bool? IsDeaf,
bool IsResident, bool IsResident,
string? ProfilePhotoUrl); string? ProfilePhotoUrl,
bool IsCastrated);
public record LitterDto( public record LitterDto(
Guid Id, Guid Id,
@@ -43,7 +45,7 @@ namespace GerbilManagerWebAPI.Dtos
DateOnly? ExpectedGoHomeDate, DateOnly? ExpectedGoHomeDate,
string? Notes); string? Notes);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes); public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
public record EnclosureDto(Guid Id, string Name, string? Notes); public record EnclosureDto(Guid Id, string Name, string? Notes);
@@ -74,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath, string? CauseOfDeath,
DateOnly? GoHomeDate, DateOnly? GoHomeDate,
string? Genotype, string? Genotype,
string? SpottingType,
string? Notes, string? Notes,
string? ImportSource, string? ImportSource,
string? ExternalRef, string? ExternalRef,
@@ -81,7 +84,8 @@ namespace GerbilManagerWebAPI.Dtos
List<string>? CharacterTraits, List<string>? CharacterTraits,
string? CharacterNote, string? CharacterNote,
bool? IsDeaf, bool? IsDeaf,
bool? IsResident); bool? IsResident,
bool? IsCastrated);
public record LitterInput( public record LitterInput(
string Name, string Name,
@@ -93,7 +97,7 @@ namespace GerbilManagerWebAPI.Dtos
DateOnly? ExpectedGoHomeDate, DateOnly? ExpectedGoHomeDate,
string? Notes); string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes); public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
public record EnclosureInput(string Name, string? Notes); public record EnclosureInput(string Name, string? Notes);

View File

@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapPost("/", async (ContactInput input, ApplicationContext db) => group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
{ {
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes }; var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver };
db.Contacts.Add(c); db.Contacts.Add(c);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c)); return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id); var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound(); if (c is null) return TypedResults.NotFound();
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes; c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return TypedResults.NoContent(); return TypedResults.NoContent();
}); });
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app; return app;
} }
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes); private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver);
} }
} }

View File

@@ -120,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath; g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate; g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
g.Genotype = i.Genotype ?? g.Genotype; g.Genotype = i.Genotype ?? g.Genotype;
g.SpottingType = i.SpottingType ?? g.SpottingType;
g.Notes = i.Notes ?? g.Notes; g.Notes = i.Notes ?? g.Notes;
g.ImportSource = i.ImportSource ?? g.ImportSource; g.ImportSource = i.ImportSource ?? g.ImportSource;
g.ExternalRef = i.ExternalRef ?? g.ExternalRef; g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
@@ -128,13 +129,14 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote; g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf; g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident); g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow)); GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
} }
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new( internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl); g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
} }
} }

View File

@@ -55,10 +55,21 @@ namespace GerbilManagerWebAPI.Import
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new(); var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new(); var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
int docxLitCount = docxLitters.Count;
int docxAnimCount = docxAnimals.Count;
int pdfLitCount = pdfLitters.Count;
int pdfAnimCount = pdfAnimals.Count;
docxLitters.AddRange(pdfLitters);
docxAnimals.AddRange(pdfAnimals);
if (docxLitters.Count == 0 && docxAnimals.Count == 0) if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{ {
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " + notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
"extract_docx.py zuerst ausführen."); "extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes); return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
} }
@@ -274,7 +285,7 @@ namespace GerbilManagerWebAPI.Import
DateOfDeath = deathDate, DateOfDeath = deathDate,
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(), CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
ColorVarietyId = cvId == default ? null : cvId, ColorVarietyId = cvId == default ? null : cvId,
OriginBreeder = "Zucht der Kleinen Chaoten", OriginBreeder = "Zucht der kleinen Chaoten",
IsResident = false, IsResident = false,
ImportSource = "docx", ImportSource = "docx",
ExternalRef = externalRef, ExternalRef = externalRef,
@@ -313,7 +324,7 @@ namespace GerbilManagerWebAPI.Import
}); });
} }
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx."); notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen)."); notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig)."); notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt."); notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");

View File

@@ -571,7 +571,7 @@ namespace GerbilManagerWebAPI.Import
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose // 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'. // 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"). // NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten"; const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
int herkunftBackfilled = 0; int herkunftBackfilled = 0;
{ {
var needsHerkunft = await _db.Gerbils var needsHerkunft = await _db.Gerbils
@@ -615,9 +615,9 @@ namespace GerbilManagerWebAPI.Import
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher."); notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
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 (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
if (herkunftBackfilled > 0) 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."); 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) 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)."); 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);

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -8,13 +9,18 @@ namespace GerbilManagerWebAPI.Import
{ {
private readonly ApplicationContext _db; private readonly ApplicationContext _db;
private readonly string _resolvedJsonPath; private readonly string _resolvedJsonPath;
private readonly string _sourceDir;
private readonly string _photoRoot;
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env) public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{ {
_db = db; _db = db;
var sourceDir = config["Import:SourcePath"] var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")); _sourceDir = config["Import:SourcePath"]
_resolvedJsonPath = Path.Combine(sourceDir, "resolved_import.json"); ?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
_photoRoot = config["Photos:RootPath"]
?? Path.Combine(contentRoot, "photo-storage");
} }
public async Task<string> RunAsync() public async Task<string> RunAsync()
@@ -76,13 +82,25 @@ namespace GerbilManagerWebAPI.Import
await _db.Litters.ExecuteDeleteAsync(); await _db.Litters.ExecuteDeleteAsync();
} }
// 2. Import Contacts (Only add new ones to preserve manually entered contacts) // 2. Import Contacts (Add new ones, and update roles/details of existing ones)
var existingContactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync(); var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
var addedContactIds = new HashSet<Guid>(); var addedContactIds = new HashSet<Guid>();
int contactsAdded = 0; int contactsAdded = 0;
int contactsUpdated = 0;
foreach (var c in data.Contacts) foreach (var c in data.Contacts)
{ {
if (!existingContactIds.Contains(c.Id) && addedContactIds.Add(c.Id)) if (existingContacts.TryGetValue(c.Id, out var existingContact))
{
existingContact.Name = c.Name;
existingContact.Email = c.Email;
existingContact.Phone = c.Phone;
existingContact.Address = c.Address;
existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
{ {
_db.Contacts.Add(c); _db.Contacts.Add(c);
contactsAdded++; contactsAdded++;
@@ -171,14 +189,45 @@ namespace GerbilManagerWebAPI.Import
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// 6. Insert all Photos // 6. Insert all Photos & copy physical files
Directory.CreateDirectory(_photoRoot);
foreach (var p in data.GerbilPhotos) foreach (var p in data.GerbilPhotos)
{ {
_db.GerbilPhotos.Add(p); if (!string.IsNullOrEmpty(p.SourcePath))
{
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var src = Path.Combine(_sourceDir, relPath);
if (File.Exists(src))
{
var dest = Path.Combine(_photoRoot, p.FileName);
try
{
File.Copy(src, dest, overwrite: true);
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
}
}
else
{
Console.WriteLine($"Warning: Source photo file not found at {src}");
}
}
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = p.Id,
GerbilId = p.GerbilId,
FileName = p.FileName,
Caption = p.Caption,
SortOrder = p.SortOrder,
CreatedAt = DateTimeOffset.UtcNow
});
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return $"Ingestion successful! Imported {contactsAdded} contacts, {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos."; return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
} }
} }
@@ -187,6 +236,18 @@ namespace GerbilManagerWebAPI.Import
public List<Contact> Contacts { get; set; } = new(); public List<Contact> Contacts { get; set; } = new();
public List<Litter> Litters { get; set; } = new(); public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { get; set; } = new(); public List<Gerbil> Gerbils { get; set; } = new();
public List<GerbilPhoto> GerbilPhotos { get; set; } = new(); public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
}
public class ResolvedPhoto
{
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
[JsonPropertyName("_source_path")]
public string? SourcePath { get; set; }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddSpottingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SpottingType",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SpottingType",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBreeder",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsReceiver",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBreeder",
table: "Contacts");
migrationBuilder.DropColumn(
name: "IsReceiver",
table: "Contacts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddIsCastrated : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCastrated",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCastrated",
table: "Gerbils");
}
}
}

View File

@@ -716,6 +716,12 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Email") b.Property<string>("Email")
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("IsBreeder")
.HasColumnType("boolean");
b.Property<bool>("IsReceiver")
.HasColumnType("boolean");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@@ -794,6 +800,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("ImportSource") b.Property<string>("ImportSource")
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("IsCastrated")
.HasColumnType("boolean");
b.Property<bool?>("IsDeaf") b.Property<bool?>("IsDeaf")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -830,6 +839,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("ReceiverContactId") b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("SpottingType")
.HasColumnType("text");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");

View File

@@ -15,5 +15,8 @@ namespace GerbilManagerWebAPI.Models
public string? Phone { get; set; } public string? Phone { get; set; }
public string? Address { get; set; } public string? Address { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsBreeder { get; set; }
public bool IsReceiver { get; set; }
} }
} }

View File

@@ -41,6 +41,8 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary> /// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
public string? Genotype { get; set; } public string? Genotype { get; set; }
public string? SpottingType { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
// Provenance (for the FEAT-8 spreadsheet import). // Provenance (for the FEAT-8 spreadsheet import).
@@ -80,6 +82,7 @@ namespace GerbilManagerWebAPI.Models
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies /// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary> /// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
public bool IsResident { get; set; } = true; public bool IsResident { get; set; } = true;
public bool IsCastrated { get; set; }
} }
/// <summary>Shared normalisation for the separator-insensitive name search.</summary> /// <summary>Shared normalisation for the separator-insensitive name search.</summary>

View File

@@ -7,9 +7,6 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/neu') await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input') .locator('input')
@@ -18,7 +15,8 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
await expect(genotypeInput).toHaveValue('') await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt. // 'Agouti' wählen → Gencode wird automatisch befüllt.
await farbschlagSelect.selectOption({ label: 'Agouti' }) await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere') await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
}) })
@@ -26,54 +24,47 @@ test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) =>
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/neu') await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input') .locator('input')
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'. // Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
await genotypeInput.fill('aa CC DD EE GG PP spsp rere') await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('cv-schwarz') await expect(page.locator('.color-picker-trigger')).toHaveText(/Schwarz/)
}) })
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => { test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/neu') await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input') .locator('input')
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist. // Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
await farbschlagSelect.selectOption({ label: 'Agouti' }) await page.locator('.color-picker-trigger').click()
await expect(farbschlagSelect).not.toHaveValue('') await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
await expect(page.locator('.color-picker-trigger')).toHaveText(/Agouti/)
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown // Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
// → Select wird auf '' (kein Eintrag) zurückgesetzt. // → Select wird auf '' (kein Eintrag) zurückgesetzt.
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere') await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('') await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
}) })
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => { test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/neu') await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input') .locator('input')
await farbschlagSelect.selectOption({ label: 'Blau' }) await page.locator('.color-picker-trigger').click()
await expect(farbschlagSelect).not.toHaveValue('') await page.locator('.color-picker-option', { hasText: 'Blau' }).click()
await expect(page.locator('.color-picker-trigger')).toHaveText(/Blau/)
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich. // aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
await genotypeInput.fill('aa CC dd EE gg pp spsp rere') await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
await expect(farbschlagSelect).toHaveValue('') await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
}) })

View File

@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] { function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
if (!orderBy) return rows if (!orderBy) return rows
const [field, dir] = orderBy.split(/\s+/) // Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1 // is "<field> [desc]"; later keys act as tiebreakers.
const keys = orderBy.split(',').map((part) => {
const [field, dir] = part.trim().split(/\s+/)
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
})
return [...rows].sort((a, b) => { return [...rows].sort((a, b) => {
const av = String(a[field] ?? '') for (const { field, sign } of keys) {
const bv = String(b[field] ?? '') const av = String(a[field] ?? '')
return av < bv ? -sign : av > bv ? sign : 0 const bv = String(b[field] ?? '')
if (av < bv) return -sign
if (av > bv) return sign
}
return 0
}) })
} }

View File

@@ -136,8 +136,10 @@ export function seedDb(): MockDb {
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' }, { ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve // Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' }, { ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' }, { ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt // ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion). // mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
{ {
@@ -189,9 +191,10 @@ export function seedDb(): MockDb {
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt. // FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
const contacts: Contact[] = [ const contacts: Contact[] = [
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null }, { id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null }, { id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' }, { id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
] ]
const colorVarieties = [ const colorVarieties = [

View File

@@ -46,7 +46,7 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
const name = uniqueName('Tier') const name = uniqueName('Tier')
await page.goto('/rennmaeuse/neu') await page.goto('/rennmaeuse/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name) await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male }) await page.getByRole('button', { name: t.genderLabels.male, exact: false }).click()
const statusField = page.locator('label.field', { const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`), has: page.locator(`span:text-is("${t.fields.status}")`),
}) })
@@ -54,3 +54,41 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
await page.getByRole('button', { name: t.form.save, exact: true }).click() await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible() await expect(page.getByRole('heading', { name })).toBeVisible()
}) })
test('Gehege-Feld Sichtbarkeit bei verstorbenen/abgegebenen Tieren', async ({ page }) => {
skipUnlessMock()
// 1. Krümel ist Breeding, hat Gehege 'Großbecken'
await page.goto('/rennmaeuse/kruemel')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
await expect(page.locator('dd', { hasText: 'Großbecken' })).toBeVisible()
// 2. Willi ist Deceased (Verstorben) -> Gehege ausgeblendet
await page.goto('/rennmaeuse/willi')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
// 3. Pauli ist GivenAway (Abgegeben), Abnehmer ist Familie Huber (isReceiver=true) -> Gehege eingeblendet
await page.goto('/rennmaeuse/pauli')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
// 4. Pauli Breeder ist GivenAway, Abnehmer ist Zoohandlung Meier (isBreeder=true) -> Gehege eingeblendet
await page.goto('/rennmaeuse/pauli-breeder')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
// 5. Pauli Neither ist GivenAway, Abnehmer ist Weder Noch (weder Züchter noch Abnehmer) -> Gehege ausgeblendet
await page.goto('/rennmaeuse/pauli-neither')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
})
test('Verstorbene Tiere: Abnehmer und Inserat-Button ausgeblendet', async ({ page }) => {
skipUnlessMock()
// Willi ist verstorben -> "Abnehmer"-Zeile ausgeblendet und "Zur Abgabe stellen"-Button ausgeblendet
await page.goto('/rennmaeuse/willi')
await expect(page.locator('dt', { hasText: t.fields.receiver })).toBeHidden()
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
// Krümel lebt -> "Zur Abgabe stellen"-Button vorhanden
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeVisible()
})

View File

@@ -52,7 +52,7 @@ test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
const name = uniqueName('Testmaus') const name = uniqueName('Testmaus')
await page.getByLabel(`${t.fields.name} *`).fill(name) await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.female }) await page.getByRole('button', { name: t.genderLabels.female, exact: false }).click()
await page.getByRole('button', { name: t.form.save, exact: true }).click() await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Erfolg: Detailseite des neuen Tieres // Erfolg: Detailseite des neuen Tieres
@@ -174,7 +174,7 @@ test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)'
// Fridolin ist fatherId von 'w-kruemel' (Wurf K) // Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin') await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible() await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
// Wurf K als Link sichtbar // Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ }) const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible() await expect(litterLink).toBeVisible()
@@ -189,6 +189,6 @@ test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTER
skipUnlessMock() skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel) // Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel') await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible() await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible() await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
}) })

View File

@@ -47,7 +47,7 @@ test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', asy
const name = uniqueName('Jungtier') const name = uniqueName('Jungtier')
await page.getByLabel(`${tg.fields.name} *`).fill(name) await page.getByLabel(`${tg.fields.name} *`).fill(name)
await page.getByLabel(`${tg.fields.gender} *`).selectOption({ label: tg.genderLabels.male }) await page.getByRole('button', { name: tg.genderLabels.male, exact: false }).click()
await page.getByRole('button', { name: tg.form.save, exact: true }).click() await page.getByRole('button', { name: tg.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible() await expect(page.getByRole('heading', { name })).toBeVisible()

View File

@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage' import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage' import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage' import HilfePage from './pages/HilfePage'
import FarbkatalogPage from './pages/FarbkatalogPage'
import VertraegeListPage from './pages/VertraegeListPage' import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage' import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage' import EinstellungenPage from './pages/EinstellungenPage'
@@ -70,6 +71,7 @@ export default function App() {
<Route path="abgabe" element={<AbgabePage />} /> <Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} /> <Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe" element={<HilfePage />} /> <Route path="hilfe" element={<HilfePage />} />
<Route path="farbkatalog" element={<FarbkatalogPage />} />
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */} {/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
<Route path="vertraege"> <Route path="vertraege">
<Route index element={<VertraegeListPage />} /> <Route index element={<VertraegeListPage />} />

View File

@@ -10,6 +10,8 @@ export interface CreateContact {
phone?: string | null phone?: string | null
address?: string | null address?: string | null
notes?: string | null notes?: string | null
isBreeder?: boolean
isReceiver?: boolean
} }
/** Payload for PUT /contacts/{id}. */ /** Payload for PUT /contacts/{id}. */

View File

@@ -22,13 +22,15 @@ export async function listEnclosures(): Promise<Enclosure[]> {
} }
export async function listContacts(): Promise<Contact[]> { export async function listContacts(): Promise<Contact[]> {
const paged = await api.get<Paged<Contact>>(`${resources.contacts}${ALL}`) const paged = await api.get<Paged<Contact>>(
`${resources.contacts}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'name' })}`
)
return paged.items return paged.items
} }
export async function listLitters(): Promise<Litter[]> { export async function listLitters(): Promise<Litter[]> {
const paged = await api.get<Paged<Litter>>( const paged = await api.get<Paged<Litter>>(
`${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`, `${resources.litters}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'date desc' })}`,
) )
return paged.items return paged.items
} }

View File

@@ -60,6 +60,8 @@ export interface Gerbil {
isDeaf?: boolean | null isDeaf?: boolean | null
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */ /** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
profilePhotoUrl?: string | null profilePhotoUrl?: string | null
spottingType?: string | null
isCastrated?: boolean
} }
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */
@@ -83,6 +85,8 @@ export interface CreateGerbil {
characterNote?: string | null characterNote?: string | null
isResident?: boolean isResident?: boolean
isDeaf?: boolean | null isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean
} }
/** Payload for PUT /gerbils/{id} (all optional / partial update). */ /** Payload for PUT /gerbils/{id} (all optional / partial update). */
@@ -108,6 +112,8 @@ export interface Contact {
phone: string | null phone: string | null
address: string | null address: string | null
notes: string | null notes: string | null
isBreeder: boolean
isReceiver: boolean
} }
export interface Litter { export interface Litter {

View File

@@ -31,6 +31,7 @@ const SECONDARY: NavItem[] = [
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' }, { to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite // WEB-0b: CMS-Verwaltung der öffentlichen Webseite
{ to: '/webseite', label: de.nav.website, icon: '🌐' }, { to: '/webseite', label: de.nav.website, icon: '🌐' },
{ to: '/farbkatalog', label: de.nav.colorCatalog, icon: '🎨' },
{ to: '/hilfe', label: de.nav.help, icon: '❓' }, { to: '/hilfe', label: de.nav.help, icon: '❓' },
] ]

View File

@@ -0,0 +1,156 @@
.color-picker-container {
position: relative;
width: 100%;
}
.color-picker-trigger {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
min-height: 44px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.color-picker-trigger:hover {
border-color: var(--color-accent);
}
.color-picker-trigger:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-soft);
}
.color-picker-trigger-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-picker-trigger-name {
font-weight: 500;
}
.color-picker-placeholder {
color: var(--color-text-muted);
}
.color-picker-caret {
color: var(--color-text-muted);
font-size: 0.8rem;
transition: transform 0.15s ease;
}
.color-picker-container[aria-expanded="true"] .color-picker-caret {
transform: rotate(180deg);
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(59, 48, 38, 0.12);
display: flex;
flex-direction: column;
max-height: 320px;
overflow: hidden;
}
.color-picker-search-wrapper {
padding: 0.5rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.color-picker-search {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 0.9rem;
min-height: 36px;
}
.color-picker-search:focus {
outline: none;
border-color: var(--color-accent);
}
.color-picker-options {
overflow-y: auto;
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.color-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.4rem 0.6rem;
border: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
border-radius: 0.375rem;
text-align: left;
min-height: 40px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.color-picker-option:hover {
background: var(--color-accent-soft);
}
.color-picker-option--selected {
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 600;
}
.color-picker-option-image-wrapper {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.color-picker-option-image-placeholder {
width: 24px;
height: 24px;
border-radius: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
flex-shrink: 0;
}
.color-picker-no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useRef } from 'react'
import { de } from '../strings/de'
import { type ColorVariety } from '../api/types'
import FarbschlagImage from './FarbschlagImage'
import './ColorVarietyPicker.css'
interface ColorVarietyPickerProps {
value: string
onChange: (id: string) => void
colorVarieties: ColorVariety[]
}
export default function ColorVarietyPicker({ value, onChange, colorVarieties }: ColorVarietyPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside of it
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Find currently selected variety
const selectedVariety = colorVarieties.find((cv) => cv.id === value)
// Filter varieties by search query
const filtered = colorVarieties.filter((cv) =>
cv.name.toLowerCase().includes(search.toLowerCase())
)
const handleSelect = (id: string) => {
onChange(id)
setIsOpen(false)
setSearch('')
}
return (
<div className="color-picker-container" ref={containerRef}>
<button
type="button"
className="color-picker-trigger"
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="color-picker-trigger-content">
{selectedVariety ? (
<>
<FarbschlagImage name={selectedVariety.name} size={24} />
<span className="color-picker-trigger-name">{selectedVariety.name}</span>
</>
) : (
<span className="color-picker-placeholder">{de.pages.gerbils.form.none}</span>
)}
</span>
<span className="color-picker-caret"></span>
</button>
{isOpen && (
<div className="color-picker-dropdown">
<div className="color-picker-search-wrapper">
<input
type="text"
className="color-picker-search"
placeholder="Farbschlag suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="color-picker-options" role="listbox">
<button
type="button"
className={`color-picker-option ${!value ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect('')}
role="option"
aria-selected={!value}
>
<div className="color-picker-option-image-placeholder" />
<span className="color-picker-option-name">{de.pages.gerbils.form.none}</span>
</button>
{filtered.map((cv) => {
const isSelected = cv.id === value
return (
<button
key={cv.id}
type="button"
className={`color-picker-option ${isSelected ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect(cv.id)}
role="option"
aria-selected={isSelected}
>
<div className="color-picker-option-image-wrapper">
<FarbschlagImage name={cv.name} size={24} />
</div>
<span className="color-picker-option-name">{cv.name}</span>
</button>
)
})}
{filtered.length === 0 && (
<div className="color-picker-no-results">
Keine Farbschläge gefunden
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -65,3 +65,54 @@
color: #d97706; color: #d97706;
white-space: nowrap; white-space: nowrap;
} }
/* Collapsible details styling for the Charakter & Eigenschaften section */
.charakter-details {
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
background: var(--color-surface, #fff);
margin-top: 1.5rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.charakter-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
background: var(--color-accent-soft, #f9f9f9);
transition: background 0.2s ease;
}
.charakter-summary::-webkit-details-marker {
display: none;
}
.charakter-summary:hover {
background: var(--color-hover, #f5f5f5);
}
.charakter-summary h3 {
margin: 0;
font-size: 1.25rem;
color: var(--color-text, #333);
}
.charakter-chevron {
font-size: 1.1rem;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.charakter-details[open] > .charakter-summary .charakter-chevron {
transform: rotate(180deg);
}
.charakter-body {
padding: 1.25rem;
border-top: 1px solid var(--color-border, #ddd);
}

View File

@@ -73,10 +73,15 @@ export function useInfiniteList<T>(
error: null, error: null,
}) })
// Tracks an in-flight request synchronously so loadMore() can't advance (and
// re-fetch/append) the same page twice if the scroll sentinel fires repeatedly.
const inFlightRef = useRef(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const page = req.page const page = req.page
const id = `${req.nonce}:${req.key}#${page}` const id = `${req.nonce}:${req.key}#${page}`
inFlightRef.current = true
loaderRef loaderRef
.current(page) .current(page)
.then((res) => { .then((res) => {
@@ -92,6 +97,9 @@ export function useInfiniteList<T>(
.catch((err) => { .catch((err) => {
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) })) if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
}) })
.finally(() => {
if (!cancelled) inFlightRef.current = false
})
return () => { return () => {
cancelled = true cancelled = true
} }
@@ -109,7 +117,10 @@ export function useInfiniteList<T>(
const error = inFlight ? null : sameQuery ? resolved.error : null const error = inFlight ? null : sameQuery ? resolved.error : null
const hasMore = sameQuery && !inFlight && items.length < total const hasMore = sameQuery && !inFlight && items.length < total
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), []) const loadMore = useCallback(() => {
if (inFlightRef.current) return
setReq((r) => ({ ...r, page: r.page + 1 }))
}, [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), []) const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload } return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }

View File

@@ -796,10 +796,149 @@ textarea {
} }
/* Profilfoto im Detail-Kopf */ /* Profilfoto im Detail-Kopf */
.profile-photo { .profile-header-summary {
width: 56px; display: flex;
height: 56px; align-items: center;
border-radius: 50%; gap: 1.5rem;
object-fit: cover; }
border: 2px solid var(--color-border);
.profile-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.profile-header-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.profile-photo {
width: 200px;
height: 200px;
border-radius: 1.25rem;
object-fit: cover;
border: 4px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-photo:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}
.form-row-2col {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 0.5rem;
}
.form-row-2col > :first-child {
flex: 2;
min-width: 0;
}
.form-row-2col > :last-child {
flex: 1;
min-width: 0;
min-height: 44px;
display: flex;
align-items: center;
}
.form-group-box {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 1.25rem;
background: var(--color-surface);
box-shadow: inset 0 1px 3px rgba(59, 48, 38, 0.02);
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0.5rem 0 1.25rem 0;
max-width: 32rem; /* match form max-width */
}
.form-group-box__title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
color: var(--color-accent);
margin-bottom: 0.25rem;
border-bottom: 1px dashed var(--color-border);
padding-bottom: 0.5rem;
}
/* Gender Segmented Control / Radio Buttons */
.gender-selector-group {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
max-width: 32rem;
}
.gender-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.gender-btn:hover {
background: var(--color-bg);
border-color: var(--color-text-muted);
}
.gender-btn__icon {
font-size: 1.15rem;
line-height: 1;
}
/* Active styles */
.gender-btn--male.is-active {
border-color: #3b82f6;
background: #eff6ff;
color: #1d4ed8;
box-shadow: 0 0 0 1px #3b82f6;
}
.gender-btn--male.is-active .gender-btn__icon {
color: #2563eb;
}
.gender-btn--female.is-active {
border-color: #ec4899;
background: #fdf2f8;
color: #be185d;
box-shadow: 0 0 0 1px #ec4899;
}
.gender-btn--female.is-active .gender-btn__icon {
color: #db2777;
}
.gender-btn--unknown.is-active {
border-color: #6b7280;
background: #f3f4f6;
color: #374151;
box-shadow: 0 0 0 1px #6b7280;
}
.gender-btn--unknown.is-active .gender-btn__icon {
color: #4b5563;
} }

View File

@@ -0,0 +1,81 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { CATALOG, farbschlagImageUrl } from '../genetics'
import './farbkatalog.css'
export default function FarbkatalogPage() {
const [search, setSearch] = useState('')
// Filter color catalog by query
const filtered = CATALOG.filter((variety) => {
const q = search.toLowerCase()
return (
variety.name.toLowerCase().includes(q) ||
(variety.english && variety.english.toLowerCase().includes(q)) ||
variety.canonicalGenotype.toLowerCase().includes(q)
);
})
return (
<section className="page farbkatalog-page">
<header className="page-head">
<div>
<h2>{de.pages.farbkatalog.title}</h2>
<p className="muted">
{de.pages.farbkatalog.subtitle.replace('{count}', String(CATALOG.length))}
</p>
</div>
</header>
<div className="farbkatalog-search-bar">
<input
type="search"
className="input"
placeholder={de.pages.farbkatalog.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="farbkatalog-grid">
{filtered.map((variety) => {
const imgUrl = farbschlagImageUrl(variety.image)
return (
<div key={variety.name} className="farbkatalog-card">
<div className="farbkatalog-card-media">
{imgUrl ? (
<img
src={imgUrl}
alt={variety.name}
className="farbkatalog-card-img"
loading="lazy"
/>
) : (
<div className="farbkatalog-card-media-placeholder">
<span>{variety.name.substring(0, 2).toUpperCase()}</span>
</div>
)}
</div>
<div className="farbkatalog-card-body">
<h4 className="farbkatalog-card-title">{variety.name}</h4>
{variety.english && (
<span className="farbkatalog-card-subtitle">{variety.english}</span>
)}
<div className="farbkatalog-card-genotype">
<code>{variety.canonicalGenotype}</code>
</div>
</div>
</div>
)
})}
{filtered.length === 0 && (
<div className="farbkatalog-empty">
<span className="farbkatalog-empty-icon">🔍</span>
<p>{de.pages.farbkatalog.empty}</p>
</div>
)}
</div>
</section>
)
}

View File

@@ -49,6 +49,7 @@ export default function GerbilDetailPage() {
const [charTraits, setCharTraits] = useState<string[]>([]) const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('') const [charNote, setCharNote] = useState('')
const [charInit, setCharInit] = useState<string | null>(null) const [charInit, setCharInit] = useState<string | null>(null)
const [isCharacterExpanded, setIsCharacterExpanded] = useState(true)
const saveCharacter = useMutation(() => const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }), updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
) )
@@ -78,6 +79,12 @@ export default function GerbilDetailPage() {
[litters.data], [litters.data],
) )
const receiverContact = useMemo(() => {
const receiverId = gerbil.data?.receiverContactId
if (!receiverId || !contacts.data) return null
return contacts.data.find((c) => c.id === receiverId) ?? null
}, [gerbil.data?.receiverContactId, contacts.data])
if (gerbil.loading) return <p className="muted">{de.common.loading}</p> if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) { if (gerbil.error || !gerbil.data) {
return ( return (
@@ -91,6 +98,11 @@ export default function GerbilDetailPage() {
} }
const g = gerbil.data const g = gerbil.data
const showEnclosure = g.status !== 'Deceased' && (
g.status !== 'GivenAway' ||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
)
const geno = describeGenotype(g.genotype) const geno = describeGenotype(g.genotype)
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—') const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
@@ -100,20 +112,25 @@ export default function GerbilDetailPage() {
setCharInit(g.id) setCharInit(g.id)
setCharTraits(g.characterTraits ?? []) setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '') setCharNote(g.characterNote ?? '')
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
} }
return ( return (
<section className="page"> <section className="page">
<header className="page-head"> <header className="page-head">
<div> <div className="profile-header-summary">
<GerbilProfilePhoto gerbilId={g.id} /> <GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name || de.pages.gerbils.nameless}</h2> <div className="profile-header-info">
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span> <h2>{g.name || de.pages.gerbils.nameless}</h2>
{g.isResident === false && ( <div className="profile-header-badges">
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}> <span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{de.pages.gerbils.externalBadge} {g.isResident === false && (
</span> <span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
)} {de.pages.gerbils.externalBadge}
</span>
)}
</div>
</div>
</div> </div>
<div className="head-actions"> <div className="head-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
@@ -122,7 +139,7 @@ export default function GerbilDetailPage() {
<Link to={`/genetik?parent=${g.id}`} className="btn"> <Link to={`/genetik?parent=${g.id}`} className="btn">
{t.detail.testMating} {t.detail.testMating}
</Link> </Link>
{g.status !== 'ForSale' && ( {g.status !== 'ForSale' && g.status !== 'Deceased' && (
<button <button
type="button" type="button"
className="btn" className="btn"
@@ -152,7 +169,15 @@ export default function GerbilDetailPage() {
<h3>{t.detail.masterData}</h3> <h3>{t.detail.masterData}</h3>
<dl className="def-list"> <dl className="def-list">
<Row label={t.fields.gender} value={genderLabel(g.gender)} /> <Row
label={t.fields.gender}
value={
<>
{genderLabel(g.gender)}
{g.isCastrated && <span style={{ marginLeft: '0.5rem', opacity: 0.8 }}>(Kastrat)</span>}
</>
}
/>
<Row label={t.fields.status} value={statusLabel(g.status)} /> <Row label={t.fields.status} value={statusLabel(g.status)} />
<Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} /> <Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} />
{g.status === 'Deceased' && ( {g.status === 'Deceased' && (
@@ -177,7 +202,18 @@ export default function GerbilDetailPage() {
) )
} }
/> />
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} /> {g.spottingType && (
<Row
label={t.fields.spottingType}
value={
de.pages.gerbils.spottingTypes[g.spottingType as keyof typeof de.pages.gerbils.spottingTypes] ||
g.spottingType
}
/>
)}
{showEnclosure && (
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
)}
<Row <Row
label={t.fields.litter} label={t.fields.litter}
value={ value={
@@ -194,7 +230,9 @@ export default function GerbilDetailPage() {
: (g.originBreeder || null) : (g.originBreeder || null)
} }
/> />
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} /> {g.status !== 'Deceased' && (
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
)}
<Row label={t.fields.notes} value={g.notes || null} /> <Row label={t.fields.notes} value={g.notes || null} />
</dl> </dl>
@@ -209,7 +247,7 @@ export default function GerbilDetailPage() {
{geno.farbschlag} {geno.farbschlag}
{storedColorName && {storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag && geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag && ( storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="mismatch-hint"> {t.detail.farbschlagMismatch}</small> <small className="mismatch-hint"> {t.detail.farbschlagMismatch}</small>
)} )}
</> </>
@@ -220,27 +258,38 @@ export default function GerbilDetailPage() {
<p className="muted">{t.detail.genotypeNotSet}</p> <p className="muted">{t.detail.genotypeNotSet}</p>
)} )}
<h3>{de.character.sectionTitle}</h3> <details
<Charakterbogen className="charakter-details"
traits={charTraits} open={isCharacterExpanded}
note={charNote} onToggle={(e) => setIsCharacterExpanded(e.currentTarget.open)}
onTraitsChange={setCharTraits} >
onNoteChange={setCharNote} <summary className="charakter-summary">
/> <h3>{de.character.sectionTitle}</h3>
<div className="form-actions"> <span className="charakter-chevron" aria-hidden="true"></span>
<button </summary>
type="button" <div className="charakter-body">
className="btn btn--primary" <Charakterbogen
disabled={saveCharacter.pending} traits={charTraits}
onClick={async () => { note={charNote}
const r = await saveCharacter.run() onTraitsChange={setCharTraits}
if (r.ok) gerbil.reload() onNoteChange={setCharNote}
}} />
> <div className="form-actions" style={{ marginTop: '1rem' }}>
{saveCharacter.pending ? t.form.saving : de.character.save} <button
</button> type="button"
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>} className="btn btn--primary"
</div> disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) gerbil.reload()
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
</div>
</details>
<h3>{t.detail.parentLittersTitle}</h3> <h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>} {parentLitters.loading && <p className="muted">{de.common.loading}</p>}

View File

@@ -3,12 +3,12 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils' import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types' import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels' import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics' import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel' import NameSuggestPanel from '../components/NameSuggestPanel'
import ColorVarietyPicker from '../components/ColorVarietyPicker'
import '../components/NameSuggestPanel.css' import '../components/NameSuggestPanel.css'
interface FormState { interface FormState {
@@ -29,11 +29,14 @@ interface FormState {
notes: string notes: string
isResident: boolean isResident: boolean
isDeaf: boolean | null isDeaf: boolean | null
spottingType: string
isSchecke: boolean
isCastrated: boolean
} }
const EMPTY: FormState = { const EMPTY: FormState = {
name: '', name: '',
gender: '', gender: 'unknown',
status: 'Breeding', status: 'Breeding',
dateOfBirth: '', dateOfBirth: '',
dateOfDeath: '', dateOfDeath: '',
@@ -49,6 +52,9 @@ const EMPTY: FormState = {
notes: '', notes: '',
isResident: true, isResident: true,
isDeaf: null, isDeaf: null,
spottingType: '',
isSchecke: false,
isCastrated: false,
} }
function formFromGerbil(g: { function formFromGerbil(g: {
@@ -69,7 +75,11 @@ function formFromGerbil(g: {
notes: string | null notes: string | null
isResident?: boolean | null isResident?: boolean | null
isDeaf?: boolean | null isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean | null
}): FormState { }): FormState {
const hasSp = g.genotype?.includes('Sp') || false
const hasSpottingType = Boolean(g.spottingType)
return { return {
name: g.name, name: g.name,
gender: g.gender, gender: g.gender,
@@ -88,6 +98,9 @@ function formFromGerbil(g: {
notes: g.notes ?? '', notes: g.notes ?? '',
isResident: g.isResident ?? true, isResident: g.isResident ?? true,
isDeaf: g.isDeaf ?? null, isDeaf: g.isDeaf ?? null,
spottingType: g.spottingType ?? '',
isSchecke: hasSp || hasSpottingType,
isCastrated: g.isCastrated ?? false,
} }
} }
@@ -154,7 +167,9 @@ export default function GerbilFormPage() {
function validate(): boolean { function validate(): boolean {
const next: Partial<Record<keyof FormState, string>> = {} const next: Partial<Record<keyof FormState, string>> = {}
if (form.name.trim() === '') next.name = t.validation.nameRequired if (form.name.trim() === '') next.name = t.validation.nameRequired
if (form.gender === '') next.gender = t.validation.genderRequired if (form.gender === 'unknown' || form.gender === '') {
next.gender = t.validation.genderRequired
}
if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid
if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) { if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
@@ -184,6 +199,8 @@ export default function GerbilFormPage() {
notes: nn(form.notes), notes: nn(form.notes),
isResident: form.isResident, isResident: form.isResident,
isDeaf: form.isDeaf, isDeaf: form.isDeaf,
spottingType: nn(form.spottingType),
isCastrated: form.isCastrated,
} }
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}`)
@@ -192,9 +209,6 @@ export default function GerbilFormPage() {
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p> if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
const selectedColorName =
(colorVarieties.data ?? []).find((cv) => cv.id === form.colorVarietyId)?.name ?? null
return ( return (
<section className="page"> <section className="page">
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2> <h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
@@ -229,22 +243,176 @@ export default function GerbilFormPage() {
)} )}
<label className="field"> <label className="field">
<span>{t.fields.gender} *</span> <span>{t.fields.origin}</span>
<select <select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
value={form.gender}
onChange={(e) => set('gender', e.target.value as Gender | '')}
aria-invalid={Boolean(errors.gender)}
>
<option value="">{t.form.none}</option> <option value="">{t.form.none}</option>
{GENDERS.map((g) => ( {(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
<option key={g} value={g}> <option key={c.id} value={c.id}>
{genderLabel(g)} {c.name}
</option> </option>
))} ))}
</select> </select>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</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>
<div className="field">
<span>{t.fields.gender} *</span>
<div className="gender-selector-group">
{(['male', 'female'] as const).map((g) => {
const active = form.gender === g
let icon = '♂'
let colorClass = 'male'
if (g === 'female') {
icon = '♀'
colorClass = 'female'
}
return (
<button
key={g}
type="button"
className={`gender-btn gender-btn--${colorClass} ${active ? 'is-active' : ''}`}
onClick={() => set('gender', active ? 'unknown' : g)}
>
<span className="gender-btn__icon">{icon}</span>
<span className="gender-btn__label">{genderLabel(g)}</span>
</button>
)
})}
</div>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</div>
<label className="field field--check">
<span>{t.fields.isCastrated}</span>
<input
type="checkbox"
checked={form.isCastrated}
onChange={(e) => set('isCastrated', e.target.checked)}
/>
</label>
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
<div className="form-group-box">
<div className="form-group-box__title">{de.pages.gerbils.form.colorSectionTitle}</div>
<div className="form-row-2col">
<div className="field">
<span>{t.fields.colorVariety}</span>
<ColorVarietyPicker
value={form.colorVarietyId}
colorVarieties={colorVarieties.data ?? []}
onChange={(newId) => {
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
/>
</div>
<label className="field field--check">
<span>{de.pages.gerbils.form.isScheckeLabel}</span>
<input
type="checkbox"
checked={form.isSchecke}
onChange={(e) => {
const checked = e.target.checked
set('isSchecke', checked)
if (!checked) {
set('spottingType', '')
}
}}
/>
</label>
</div>
{form.isSchecke && (
<label className="field">
<span>{t.fields.spottingType}</span>
<select
value={form.spottingType}
onChange={(e) => set('spottingType', e.target.value)}
>
<option value="">{de.pages.gerbils.spottingTypes.none}</option>
{Object.keys(de.pages.gerbils.spottingTypes)
.filter((key) => key !== 'none')
.map((key) => (
<option key={key} value={key}>
{de.pages.gerbils.spottingTypes[key as keyof typeof de.pages.gerbils.spottingTypes]}
</option>
))}
</select>
</label>
)}
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.includes('Sp')) {
set('isSchecke', true)
}
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</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>
</div>
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */} {/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
{form.status === 'Deceased' || form.status === 'GivenAway' ? ( {form.status === 'Deceased' || form.status === 'GivenAway' ? (
<div className="field"> <div className="field">
@@ -269,16 +437,6 @@ export default function GerbilFormPage() {
</label> </label>
)} )}
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
{form.status === 'Deceased' && ( {form.status === 'Deceased' && (
<> <>
<label className="field"> <label className="field">
@@ -315,31 +473,6 @@ export default function GerbilFormPage() {
</label> </label>
)} )}
<label className="field">
<span>{t.fields.colorVariety}</span>
<span className="farbschlag-value">
<select
value={form.colorVarietyId}
onChange={(e) => {
const newId = e.target.value
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
>
<option value="">{t.form.none}</option>
{(colorVarieties.data ?? []).map((cv) => (
<option key={cv.id} value={cv.id}>
{cv.name}
</option>
))}
</select>
{selectedColorName && <FarbschlagImage name={selectedColorName} size={36} />}
</span>
</label>
<label className="field"> <label className="field">
<span>{t.fields.enclosure}</span> <span>{t.fields.enclosure}</span>
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}> <select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
@@ -364,27 +497,7 @@ export default function GerbilFormPage() {
</select> </select>
</label> </label>
<label className="field">
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</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>
@@ -393,7 +506,7 @@ export default function GerbilFormPage() {
onChange={(e) => set('receiverContactId', e.target.value)} onChange={(e) => set('receiverContactId', e.target.value)}
> >
<option value="">{t.form.none}</option> <option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => ( {(contacts.data ?? []).filter((c) => c.isReceiver || c.id === form.receiverContactId).map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} {c.name}
</option> </option>
@@ -401,55 +514,16 @@ export default function GerbilFormPage() {
</select> </select>
</label> </label>
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</label>
<label className="field"> <label className="field">
<span>{t.fields.notes}</span> <span>{t.fields.notes}</span>
<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>

View File

@@ -96,6 +96,17 @@ export default function KontaktDetailPage() {
{deleteError && <div className="alert alert--error">{deleteError}</div>} {deleteError && <div className="alert alert--error">{deleteError}</div>}
<dl className="def-list"> <dl className="def-list">
<div className="def-row">
<dt>Rolle</dt>
<dd>
{[
c.isBreeder ? 'Züchter' : null,
c.isReceiver ? 'Abnehmer' : null,
]
.filter(Boolean)
.join(' · ') || '—'}
</dd>
</div>
<div className="def-row"> <div className="def-row">
<dt>{t.fields.email}</dt> <dt>{t.fields.email}</dt>
<dd>{c.email ?? '—'}</dd> <dd>{c.email ?? '—'}</dd>

View File

@@ -12,9 +12,11 @@ interface FormState {
phone: string phone: string
address: string address: string
notes: string notes: string
isBreeder: boolean
isReceiver: boolean
} }
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' } const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '', isBreeder: false, isReceiver: true }
/** "" -> null, sonst der Wert. */ /** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s) const nn = (s: string): string | null => (s.trim() === '' ? null : s)
@@ -40,6 +42,8 @@ export default function KontaktFormPage() {
phone: existing.data.phone ?? '', phone: existing.data.phone ?? '',
address: existing.data.address ?? '', address: existing.data.address ?? '',
notes: existing.data.notes ?? '', notes: existing.data.notes ?? '',
isBreeder: existing.data.isBreeder,
isReceiver: existing.data.isReceiver,
}) })
} }
@@ -63,6 +67,8 @@ export default function KontaktFormPage() {
phone: nn(form.phone), phone: nn(form.phone),
address: nn(form.address), address: nn(form.address),
notes: nn(form.notes), notes: nn(form.notes),
isBreeder: form.isBreeder,
isReceiver: form.isReceiver,
}) })
if (result.ok) navigate(`/kontakte/${result.value.id}`) if (result.ok) navigate(`/kontakte/${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.
@@ -116,6 +122,24 @@ export default function KontaktFormPage() {
<small className="muted">{t.form.addressHint}</small> <small className="muted">{t.form.addressHint}</small>
</label> </label>
<label className="field field--check">
<span>{t.fields.isBreeder}</span>
<input
type="checkbox"
checked={form.isBreeder}
onChange={(e) => set('isBreeder', e.target.checked)}
/>
</label>
<label className="field field--check">
<span>{t.fields.isReceiver}</span>
<input
type="checkbox"
checked={form.isReceiver}
onChange={(e) => set('isReceiver', e.target.checked)}
/>
</label>
<label className="field"> <label className="field">
<span>{t.fields.notes}</span> <span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} /> <textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />

View File

@@ -73,7 +73,39 @@ export default function KontaktePage() {
{items.map((c) => ( {items.map((c) => (
<li key={c.id}> <li key={c.id}>
<Link to={`/kontakte/${c.id}`} className="gerbil-card"> <Link to={`/kontakte/${c.id}`} className="gerbil-card">
<span className="gerbil-card__name">{c.name}</span> <span className="gerbil-card__name">
{c.name}
{c.isBreeder && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
background: '#e0f2fe',
color: '#0369a1',
fontWeight: 'normal',
}}
>
Züchter
</span>
)}
{c.isReceiver && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
background: '#fef3c7',
color: '#b45309',
fontWeight: 'normal',
}}
>
Abnehmer
</span>
)}
</span>
<span className="gerbil-card__meta"> <span className="gerbil-card__meta">
{[c.phone, c.email].filter(Boolean).join(' · ')} {[c.phone, c.email].filter(Boolean).join(' · ')}
</span> </span>

View File

@@ -47,7 +47,7 @@ export default function VertragWizardPage() {
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' }) const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), []) const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactItems = useMemo(() => contacts.data?.items ?? [], [contacts.data]) const contactItems = useMemo(() => (contacts.data?.items ?? []).filter((c) => c.isReceiver), [contacts.data])
const filteredContacts = useMemo(() => { const filteredContacts = useMemo(() => {
const needle = contactSearch.trim().toLowerCase() const needle = contactSearch.trim().toLowerCase()
return needle === '' return needle === ''
@@ -62,6 +62,8 @@ export default function VertragWizardPage() {
email: newContact.email.trim() || null, email: newContact.email.trim() || null,
phone: newContact.phone.trim() || null, phone: newContact.phone.trim() || null,
address: newContact.address.trim() || null, address: newContact.address.trim() || null,
isReceiver: true,
isBreeder: false,
}), }),
) )
async function onCreateContact() { async function onCreateContact() {

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { listLitters } from '../api/litters' import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils' import { listGerbils } from '../api/gerbils'
import { andFilter, condition, type GridifyQuery } from '../api/gridify' import { andFilter, condition, escapeGridifyValue, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types' import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels' import { formatDate } from '../format/labels'
@@ -17,7 +17,7 @@ const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc:
function yearOptions(): number[] { function yearOptions(): number[] {
const current = new Date().getFullYear() const current = new Date().getFullYear()
return Array.from({ length: 16 }, (_, i) => current - i) return Array.from({ length: 30 }, (_, i) => current - i)
} }
interface Pair { interface Pair {
@@ -47,6 +47,7 @@ function derivePairs(litters: Litter[]): Pair[] {
export default function WuerfeListPage() { export default function WuerfeListPage() {
const t = de.pages.litters const t = de.pages.litters
const [tab, setTab] = useState<Tab>('litters') const [tab, setTab] = useState<Tab>('litters')
const [search, setSearch] = useState('')
const [year, setYear] = useState('') const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc') const [sort, setSort] = useState<SortKey>('dateDesc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -62,6 +63,7 @@ export default function WuerfeListPage() {
// Litters tab (paged, filtered). // Litters tab (paged, filtered).
const filter = andFilter( const filter = andFilter(
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }), year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }), year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
) )
@@ -76,6 +78,16 @@ export default function WuerfeListPage() {
) )
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data]) const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
// Search-enabled pairs list (client-side search by parent name)
const filteredPairs = useMemo(() => {
if (!search.trim()) return pairs
const term = search.toLowerCase()
return pairs.filter((p) =>
parentName(p.fatherId).toLowerCase().includes(term) ||
parentName(p.motherId).toLowerCase().includes(term)
)
}, [pairs, search, nameById])
const total = litters.data?.totalCount ?? 0 const total = litters.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = litters.data?.items ?? [] const items = litters.data?.items ?? []
@@ -113,8 +125,26 @@ export default function WuerfeListPage() {
{tab === 'litters' && ( {tab === 'litters' && (
<> <>
<FilterPanel <FilterPanel
activeCount={year !== '' ? 1 : 0} searchField={
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }} <input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
aria-label="Wurf suchen"
/>
}
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
onReset={() => {
setSearch('')
setYear('')
setSort('dateDesc')
setPage(1)
}}
> >
<label className="field"> <label className="field">
<span>{t.filterYear}</span> <span>{t.filterYear}</span>
@@ -202,11 +232,31 @@ export default function WuerfeListPage() {
{tab === 'pairs' && ( {tab === 'pairs' && (
<> <>
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
<input
type="search"
className="input"
style={{ maxWidth: '320px' }}
placeholder="Elternteil suchen …"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Elternteil suchen"
/>
{search && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => setSearch('')}
>
{de.pages.gerbils.filters.reset}
</button>
)}
</div>
{allLitters.loading && <p className="muted">{de.common.loading}</p>} {allLitters.loading && <p className="muted">{de.common.loading}</p>}
{!allLitters.loading && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>} {!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{pairs.length > 0 && ( {filteredPairs.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{pairs.map((p) => ( {filteredPairs.map((p) => (
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card"> <li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
<span className="gerbil-card__name"> <span className="gerbil-card__name">
{parentName(p.fatherId)} × {parentName(p.motherId)} {parentName(p.fatherId)} × {parentName(p.motherId)}

View File

@@ -0,0 +1,142 @@
.farbkatalog-page {
display: flex;
flex-direction: column;
}
.farbkatalog-search-bar {
margin: 1.5rem 0;
max-width: 36rem;
width: 100%;
}
.farbkatalog-search-bar .input {
box-shadow: 0 2px 8px rgba(59, 48, 38, 0.04);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.farbkatalog-search-bar .input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-soft);
}
.farbkatalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
margin-top: 0.5rem;
}
.farbkatalog-card {
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(59, 48, 38, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.farbkatalog-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(59, 48, 38, 0.08);
border-color: var(--color-accent);
}
.farbkatalog-card-media {
position: relative;
width: 100%;
aspect-ratio: 16/11;
background: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-border);
}
.farbkatalog-card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.farbkatalog-card:hover .farbschlag-card-img,
.farbkatalog-card:hover .farbkatalog-card-img {
transform: scale(1.05);
}
.farbkatalog-card-media-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-accent-soft) 0%, #ecd0b9 100%);
color: var(--color-accent);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: 0.1em;
}
.farbkatalog-card-body {
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.farbkatalog-card-title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.25;
}
.farbkatalog-card-subtitle {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.15rem;
display: block;
}
.farbkatalog-card-genotype {
margin-top: auto;
padding-top: 0.75rem;
}
.farbkatalog-card-genotype code {
font-family: monospace;
font-size: 0.72rem;
background: var(--color-bg);
color: var(--color-accent);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
display: inline-block;
max-width: 100%;
overflow-x: auto;
}
.farbkatalog-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 0.75rem;
text-align: center;
color: var(--color-text-muted);
}
.farbkatalog-empty-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
@media (min-width: 768px) {
.farbkatalog-grid {
gap: 1.25rem;
}
}

View File

@@ -66,6 +66,42 @@
padding: 0.75rem 0; padding: 0.75rem 0;
} }
/* Back-to-top: floating round button, above the mobile action bar. */
.rmf-backtotop {
position: fixed;
right: 1rem;
bottom: calc(4.75rem + env(safe-area-inset-bottom));
z-index: 25;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
background: var(--color-surface);
color: var(--color-accent);
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 6px rgba(61, 46, 35, 0.12), 0 8px 22px rgba(61, 46, 35, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
animation: gerbilRowIn 0.2s cubic-bezier(0.22, 1, 0.36, 1);
transition: transform 0.12s cubic-bezier(0.22, 1, 0.36, 1), background 0.15s;
}
.rmf-backtotop:hover {
background: var(--color-accent-soft);
}
.rmf-backtotop:active {
transform: scale(0.92);
}
@media (min-width: 768px) {
/* Desktop has a sidebar, not a bottom bar — sit at the normal bottom inset. */
.rmf-backtotop {
bottom: 1.5rem;
}
}
/* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ---------- /* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ----------
The whole header (title + search + filter pills + bar) sticks to the top; the list The whole header (title + search + filter pills + bar) sticks to the top; the list
scrolls underneath. The title row collapses once a 1px sentinel above the header scrolls underneath. The title row collapses once a 1px sentinel above the header

View File

@@ -34,6 +34,7 @@ export const de = {
help: 'Hilfe', help: 'Hilfe',
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite // WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
website: 'Webseite', website: 'Webseite',
colorCatalog: 'Farbkatalog',
}, },
pages: { pages: {
gerbils: { gerbils: {
@@ -46,6 +47,8 @@ export const de = {
searchPlaceholder: 'Name suchen …', searchPlaceholder: 'Name suchen …',
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte. // DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
born: 'geb.', born: 'geb.',
// Zurück-nach-oben-Button (erscheint beim Scrollen der langen Liste).
backToTop: 'Nach oben',
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere // BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
externalBadge: 'Extern', externalBadge: 'Extern',
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.', externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
@@ -88,7 +91,9 @@ export const de = {
isDeaf: 'Gehörlos', isDeaf: 'Gehörlos',
receiver: 'Abnehmer', receiver: 'Abnehmer',
genotype: 'Genotyp', genotype: 'Genotyp',
isCastrated: 'Kastrat',
notes: 'Notizen', notes: 'Notizen',
spottingType: 'Scheckungsart',
}, },
// Detailseite // Detailseite
detail: { detail: {
@@ -128,6 +133,8 @@ export const de = {
isDeafUnknown: 'Unbekannt', isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja', isDeafYes: 'Ja',
isDeafNo: 'Nein', isDeafNo: 'Nein',
isScheckeLabel: 'Ist eine Schecke',
colorSectionTitle: 'Farbe & Genetik',
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte // STATUS-MODEL: Hinweis für abgeleitete Statuswerte
statusDerivedHint: { statusDerivedHint: {
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.', Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
@@ -158,6 +165,17 @@ export const de = {
GivenAway: 'Abgegeben', GivenAway: 'Abgegeben',
ForSale: 'Zur Abgabe', ForSale: 'Zur Abgabe',
}, },
spottingTypes: {
none: '— keine Scheckungsart —',
Punktschecke: 'Punktschecke',
Kragenschecke: 'Kragenschecke',
Ansatzschecke: 'Ansatzschecke',
Starkschecke: 'Starkschecke',
Superschecke: 'Superschecke',
'DP (Dark Patch)': 'DP (Dark Patch)',
WP: 'Minimalschecke (WP)',
wp: 'WP',
},
}, },
// ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ── // ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ──
litters: { litters: {
@@ -170,6 +188,7 @@ export const de = {
newButton: 'Neuer Wurf', newButton: 'Neuer Wurf',
empty: 'Keine Würfe gefunden.', empty: 'Keine Würfe gefunden.',
countLabel: 'Würfe', countLabel: 'Würfe',
searchPlaceholder: 'Wurf suchen …',
filterYear: 'Jahr', filterYear: 'Jahr',
allYears: 'Alle Jahre', allYears: 'Alle Jahre',
sort: { sort: {
@@ -239,6 +258,12 @@ export const de = {
title: 'Züchter', title: 'Züchter',
comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.', comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.',
}, },
farbkatalog: {
title: 'Farbkatalog',
subtitle: 'Übersicht aller {count} registrierten Farbschläge und deren genetischen Formeln.',
searchPlaceholder: 'Nach Name, englischem Begriff oder Genen suchen (z. B. \'Zobel\', \'dd\', \'ee\', \'pp\')...',
empty: 'Keine Farbschläge gefunden, die Ihrer Suche entsprechen.',
},
notFound: { notFound: {
title: 'Seite nicht gefunden', title: 'Seite nicht gefunden',
message: 'Die angeforderte Seite existiert nicht.', message: 'Die angeforderte Seite existiert nicht.',
@@ -570,6 +595,8 @@ export const de = {
phone: 'Telefon', phone: 'Telefon',
address: 'Adresse', address: 'Adresse',
notes: 'Notizen', notes: 'Notizen',
isBreeder: 'Ist Züchter (Zucht)',
isReceiver: 'Ist Abnehmer (Käufer)',
}, },
linked: { linked: {
title: 'Verknüpfte Tiere', title: 'Verknüpfte Tiere',

View File

@@ -213,6 +213,35 @@
"genotype": "AA CC DD ee Gg pp Spsp", "genotype": "AA CC DD ee Gg pp Spsp",
"farbschlag": "Goldfuchs Starkschecke", "farbschlag": "Goldfuchs Starkschecke",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)" "source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
},
{
"name": "Joghurt von Privat",
"dob": "06.09.2013",
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
"source": "Julian 2026-06-12"
},
{
"name": "Ken'ichi",
"dob": "01.03.2015",
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
"genotype": "AA CC DD Ee GG PP spsp",
"farbschlag": "Agouti",
"source": "Julian 2026-06-12"
},
{
"name": "Harumi",
"dob": "21.02.2015",
"decision": "Death date is 11.01.2018 (died in 2018).",
"dateOfDeath": "11.01.2018",
"source": "Julian 2026-06-12"
},
{
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
"dob": "18.05.2016",
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
"source": "Julian 2026-06-12"
} }
] ]
} }

View File

@@ -35,19 +35,20 @@ DEATH = re.compile(r"\+\s?(\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2})|\d{4})")
# ---------------------------------------------------------------- helpers ---- # ---------------------------------------------------------------- helpers ----
def gen_of(colnum): def gen_of(colnum, offset=0):
"""Map a column number to a generation band (0=proband ... 5=deepest).""" """Map a column number to a generation band (0=proband ... 5=deepest)."""
if colnum <= 6: effective_col = colnum - offset
return 0 # E band (proband / "Kids") if effective_col <= 3:
if colnum <= 9: return 0 # Column B (2) -> proband
return 1 # H band (parents) if effective_col <= 6:
if colnum <= 12: return 1 # Column E (5) -> parents
return 2 # K band (grandparents) if effective_col <= 9:
if colnum <= 15: return 2 # Column H (8) -> grandparents
return 3 # N band (great-grandparents) if effective_col <= 12:
if colnum <= 17: return 3 # Column K (11) -> great-grandparents
return 4 # Q band (gg-grandparents) if effective_col <= 15:
return 5 # R/S band (name-pairs) return 4 # Column N (14) -> gg-grandparents
return 5 # Column Q (17) or deeper -> ggg-grandparents
def norm_name(name): def norm_name(name):
@@ -189,6 +190,9 @@ def extract_stammbaum(path):
cells = xu.read_cells(z, sheets[0], ss) cells = xu.read_cells(z, sheets[0], ss)
fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female) fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female)
has_col2 = any(c == 2 for (c, r) in cells)
col_offset = 0 if has_col2 else 3
# group cells by column for block reconstruction # group cells by column for block reconstruction
by_col = {} by_col = {}
for (c, r), t in cells.items(): for (c, r), t in cells.items():
@@ -230,7 +234,7 @@ def extract_stammbaum(path):
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks # WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in # (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag. # deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
deep_band = gen_of(c) >= 2 deep_band = gen_of(c, col_offset) >= 2
for rr in range(r + 1, r + 4): for rr in range(r + 1, r + 4):
cell = cells.get((c, rr)) cell = cells.get((c, rr))
if not cell: if not cell:
@@ -269,7 +273,7 @@ def extract_stammbaum(path):
"parentRefs": [], "parentRefs": [],
"photos": [], "photos": [],
"sourceFiles": [fname], "sourceFiles": [fname],
"_gen": gen_of(c), "_gen": gen_of(c, col_offset),
"_col": c, "_col": c,
"_row": r, "_row": r,
"_file": fname, "_file": fname,
@@ -280,7 +284,7 @@ def extract_stammbaum(path):
for (c, r), t in cells.items(): for (c, r), t in cells.items():
if (c, r) in used: if (c, r) in used:
continue continue
if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c) >= 4: if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c, col_offset) >= 4:
for part in t.split(" & "): for part in t.split(" & "):
part = clean_name(part) part = clean_name(part)
if part: if part:
@@ -291,7 +295,7 @@ def extract_stammbaum(path):
"genotype": gt.parse(""), "deaf": None, "tags": [], "genotype": gt.parse(""), "deaf": None, "tags": [],
"breeder": "", "zucht": zraw, "breeder": "", "zucht": zraw,
"parentRefs": [], "photos": [], "sourceFiles": [fname], "parentRefs": [], "photos": [], "sourceFiles": [fname],
"_gen": gen_of(c), "_col": c, "_row": r, "_file": fname, "_gen": gen_of(c, col_offset), "_col": c, "_row": r, "_file": fname,
"_zucht": norm_zucht(zraw), "_zucht": norm_zucht(zraw),
}) })
@@ -337,8 +341,9 @@ def _attach_photos(z, sheets, animals, fname):
for a in animals: for a in animals:
by_gen.setdefault(a["_gen"], []).append(a) by_gen.setdefault(a["_gen"], []).append(a)
media_dir = os.path.join(OUT, "photos") media_dir = os.path.join(OUT, "photos")
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
for i, (sp, col, row, media) in enumerate(anchors): for i, (sp, col, row, media) in enumerate(anchors):
g = gen_of(col) g = gen_of(col, col_offset)
cands = by_gen.get(g, []) cands = by_gen.get(g, [])
if not cands: if not cands:
# fall back to nearest animal by row across all gens # fall back to nearest animal by row across all gens
@@ -1026,6 +1031,9 @@ def main():
print(f" {len(got):4d} {os.path.basename(path)}") print(f" {len(got):4d} {os.path.basename(path)}")
raw_animals.extend(got) raw_animals.extend(got)
# Skip pseudo-animal records (like "DD-Tumor bei Geschwister") that are actually notes
raw_animals = [a for a in raw_animals if "DD-Tumor" not in a["name"]]
litters = [] litters = []
if os.path.isfile(args.wurfchronik): if os.path.isfile(args.wurfchronik):
litters = extract_wurfchronik(args.wurfchronik) litters = extract_wurfchronik(args.wurfchronik)

View File

@@ -68,11 +68,18 @@ def _sls_alleles(token):
def _deaf_value(token): def _deaf_value(token):
"""dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None. Case-sensitive for Dea/dea.""" """dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None."""
t = token.strip("()[]") t = token.strip("()[]")
if t == "dea" or t.lower() == "taub": t_lower = t.lower()
if "taub" in t_lower:
return True return True
if t == "Dea" or t.lower() in ("hörend", "hoerend"): if "hörend" in t_lower or "hoerend" in t_lower:
return False
if "dea/dea" in t_lower or "dea/*" in t_lower:
return True
if t == "dea":
return True
if t == "Dea":
return False return False
return None return None

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,15 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick ## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **2451** - Rohe Tier-Einträge aus den Stammbäumen: **2449**
- Nach Zusammenführung (eindeutige Tiere): **1007** - Nach Zusammenführung (eindeutige Tiere): **1006**
- davon mit Geburtsdatum: 682 - davon mit Geburtsdatum: 681
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 461 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
- Konflikte zur Klärung: **7** - Konflikte zur Klärung: **2**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
- Fotos zugeordnet: **417** - Fotos zugeordnet: **417**
- Würfe aus der Wurfchronik: **752** - Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **274** (davon über Geburtsdatum **und** Eltern: 175, nur über Geburtsdatum: 99; mehrdeutig: 14) - Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 167, nur über Geburtsdatum: 103; mehrdeutig: 17)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema) - Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
## Zusammenführungs-Schlüssel ## Zusammenführungs-Schlüssel
@@ -34,12 +34,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien | | Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Joghurt von Privat | 06.09.2013 | aa CC D- ee GG PP spsp // aa Cc[-] D- ee UwUw PP spsp | Privat | 06.03.2017 | Stammbaum von Akio Kids, Stammbaum von Jin, Stammbaum von Kentucky | | Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Ken'ichi | 01.03.2015 | AA CC DD Ee GG PP spsp | Agouti // DD-Tumor | 02.11.2018 | Stammbaum von Chesnut, Stammbaum von Emi, Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi |
| DD-Tumor bei Geschwister | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 | Stammbaum von Chesnut, Stammbaum von Emi |
| Kleiner Warnowrenner Elieus gen. Eragon | 18.05.2016 | aa CC D- Ee Gg pp Spsp [DP] // aa c[chm]c[chm] D- Ee Gg pp Spsp | — | 09.10.2019 | Stammbaum von Chesnut, Stammbaum von Jeremy, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Quied Soldier of Black Forest |
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | Polarfuchs | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Harumi | 21.02.2015 | aa Cc[chm] DD EE GG PP Spsp | — | 03.06.2017 // 11.01.2018 | Stammbaum von Danako, Stammbaum von Kalea, Stammbaum von Yurikas und Pintos Sohn |
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) | | Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
## Mehrdeutige / unvollständige Einträge ## Mehrdeutige / unvollständige Einträge
@@ -160,7 +155,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|---|---|---| |---|---|---|
| `/+` | 7 | ? | | `/+` | 7 | ? |
| `[meliert]` | 4 | ? | | `[meliert]` | 4 | ? |
| `[Dea/dea]` | 4 | ? |
| `(Kragen)` | 3 | ? | | `(Kragen)` | 3 | ? |
| `/` | 3 | ? | | `/` | 3 | ? |
| `-g` | 2 | ? | | `-g` | 2 | ? |
@@ -168,7 +162,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `Tumor` | 2 | ? | | `Tumor` | 2 | ? |
| `!` | 2 | ? | | `!` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) | | `C(C)` | 2 | Schreibweise (C trägt c) |
| `[dea/*]` | 2 | ? |
| `(KW` | 2 | ? | | `(KW` | 2 | ? |
| `Cc[]` | 1 | ? | | `Cc[]` | 1 | ? |
| `[WFNZ-Maroon]` | 1 | ? | | `[WFNZ-Maroon]` | 1 | ? |
@@ -176,13 +169,15 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `[meliert]-[DP]` | 1 | ? | | `[meliert]-[DP]` | 1 | ? |
| `!Knickschwanz-Gen!` | 1 | ? | | `!Knickschwanz-Gen!` | 1 | ? |
| `-09.09.2018` | 1 | ? | | `-09.09.2018` | 1 | ? |
| `[dea/dea]` | 1 | ? |
| `[DP?]` | 1 | ? | | `[DP?]` | 1 | ? |
| `/+Dezember'2014` | 1 | ? | | `/+Dezember'2014` | 1 | ? |
| `(Ansatz)` | 1 | ? | | `(Ansatz)` | 1 | ? |
| `[WFNZ][dea/*]` | 1 | ? |
| `-psp` | 1 | ? | | `-psp` | 1 | ? |
| `G(G)` | 1 | ? | | `G(G)` | 1 | ? |
| `!Niereninsuffizienz!` | 1 | ? |
| `+09.07.2017` | 1 | ? |
| `+2018` | 1 | ? |
| `AAA` | 1 | ? |
## Wurfchronik — Datenqualitäts-Hinweise ## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -344,13 +344,28 @@ def main():
# Attach Photos # Attach Photos
for idx, photo_rel in enumerate(a.get("photos", [])): for idx, photo_rel in enumerate(a.get("photos", [])):
photo_guid = generate_guid(f"photo-{photo_rel}") photo_guid = generate_guid(f"photo-{photo_rel}")
ext = os.path.splitext(photo_rel)[1] or ".jpeg"
fn_guid = photo_guid.replace("-", "")
resolved_photos.append({ resolved_photos.append({
"Id": photo_guid, "Id": photo_guid,
"GerbilId": a_guid, "GerbilId": a_guid,
"FileName": os.path.basename(photo_rel), "FileName": f"{fn_guid}{ext}",
"SortOrder": idx "SortOrder": idx,
"_source_path": photo_rel
}) })
# Set IsBreeder and IsReceiver flags on contacts
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
for c in resolved_contacts:
c_id = c["Id"]
is_breeder = c_id in breeder_ids
is_receiver = c_id in receiver_ids
if not is_breeder and not is_receiver:
is_receiver = True
c["IsBreeder"] = is_breeder
c["IsReceiver"] = is_receiver
# Save resolved data # Save resolved data
resolved_data = { resolved_data = {
"Contacts": resolved_contacts, "Contacts": resolved_contacts,

View File

@@ -0,0 +1,65 @@
# run_app_and_import.ps1
# This script starts the .NET Aspire AppHost and automatically runs the database import once the WebAPI is ready.
Write-Host "Starting GerbilManager AppHost (this will boot PostgreSQL and the WebAPI)..." -ForegroundColor Cyan
# Start AppHost in the background
$appProcess = Start-Process dotnet -ArgumentList "run --project GerbilManager.AppHost" -PassThru -NoNewWindow
# Function to cleanup process on exit
function Cleanup {
Write-Host "`nStopping AppHost..." -ForegroundColor Yellow
if ($appProcess -and -not $appProcess.HasExited) {
Stop-Process -Id $appProcess.Id -Force
}
}
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup }
try {
# Poll the import endpoint until it becomes responsive (max 60 seconds)
$url = "http://localhost:5179/import/ingest-resolved"
$success = $false
Write-Host "Waiting for WebAPI to become responsive at $url..." -ForegroundColor Cyan
for ($i = 0; $i -lt 30; $i++) {
try {
# Send a test request or check port
$response = Invoke-WebRequest -Uri "http://localhost:5179/openapi/v1.json" -Method Get -TimeoutSec 2 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
$success = $true
break
}
}
catch {
# WebAPI not ready yet
Start-Sleep -Seconds 2
}
}
if (-not $success) {
Write-Host "Error: WebAPI did not become ready within 60 seconds." -ForegroundColor Red
Cleanup
exit 1
}
Write-Host "WebAPI is up! Triggering the database import..." -ForegroundColor Green
# Run the import POST request
try {
$importResponse = Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json"
Write-Host "Import Result: $importResponse" -ForegroundColor Green
Write-Host "`nThe application is now fully running and imported!" -ForegroundColor Cyan
Write-Host "Press Ctrl+C in this terminal to stop the application." -ForegroundColor Yellow
# Keep script running to keep the app process alive
while ($true) {
Start-Sleep -Seconds 1
}
}
catch {
Write-Host "Error during database import: $_" -ForegroundColor Red
}
}
finally {
Cleanup
}

View File

@@ -68,8 +68,8 @@ finally:
except OSError: pass except OSError: pass
gen = e.gen_of gen = e.gen_of
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2) check("gen_of: early bands B, E, H", gen(2) == 0 and gen(5) == 1 and gen(8) == 2)
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2) check("gen_of: deep bands K, N, Q", gen(11) == 3 and gen(14) == 4 and gen(17) == 5)
# --- conflict-decisions consumption (HUMANQUESTION D / C6) --- # --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json") dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")