Compare commits
10 Commits
feat/rennm
...
bddbaf59d3
| Author | SHA1 | Date | |
|---|---|---|---|
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 |
@@ -122,7 +122,7 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||
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.Equal("docx", gerbil.ImportSource);
|
||||
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||
|
||||
@@ -36,7 +36,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
name,
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
originBreeder = "Zucht der Kleinen Chaoten",
|
||||
originBreeder = "Zucht der kleinen Chaoten",
|
||||
isDeaf = false,
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
@@ -67,7 +67,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
@@ -95,7 +95,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
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("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.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -30,7 +31,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl);
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -43,7 +45,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
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);
|
||||
|
||||
@@ -74,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -81,7 +84,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
bool? IsResident,
|
||||
bool? IsCastrated);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
@@ -93,7 +97,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
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);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
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);
|
||||
await db.SaveChangesAsync();
|
||||
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);
|
||||
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.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||
g.Genotype = i.Genotype ?? g.Genotype;
|
||||
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
||||
g.Notes = i.Notes ?? g.Notes;
|
||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||
@@ -128,13 +129,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
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.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
|
||||
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
var docxLitters = Load<List<DocxLitter>>("docx_litters.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)
|
||||
{
|
||||
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
||||
"extract_docx.py zuerst ausführen.");
|
||||
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 oder PDF-VLM-Daten bereitstellen.");
|
||||
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||
}
|
||||
|
||||
@@ -274,7 +285,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
DateOfDeath = deathDate,
|
||||
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||
ColorVarietyId = cvId == default ? null : cvId,
|
||||
OriginBreeder = "Zucht der Kleinen Chaoten",
|
||||
OriginBreeder = "Zucht der kleinen Chaoten",
|
||||
IsResident = false,
|
||||
ImportSource = "docx",
|
||||
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($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||
|
||||
@@ -571,7 +571,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
||||
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
|
||||
int herkunftBackfilled = 0;
|
||||
{
|
||||
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.");
|
||||
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($"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)
|
||||
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)
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -8,13 +9,18 @@ namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
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;
|
||||
var sourceDir = config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output"));
|
||||
_resolvedJsonPath = Path.Combine(sourceDir, "resolved_import.json");
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_sourceDir = config["Import:SourcePath"]
|
||||
?? 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()
|
||||
@@ -57,6 +63,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||
_db.Litters.RemoveRange(_db.Litters);
|
||||
_db.Contacts.RemoveRange(_db.Contacts);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
@@ -74,15 +81,28 @@ namespace GerbilManagerWebAPI.Import
|
||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||
await _db.Gerbils.ExecuteDeleteAsync();
|
||||
await _db.Litters.ExecuteDeleteAsync();
|
||||
await _db.Contacts.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 2. Import Contacts (Only add new ones to preserve manually entered contacts)
|
||||
var existingContactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
||||
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
|
||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||
var addedContactIds = new HashSet<Guid>();
|
||||
int contactsAdded = 0;
|
||||
int contactsUpdated = 0;
|
||||
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);
|
||||
contactsAdded++;
|
||||
@@ -171,14 +191,45 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Insert all Photos
|
||||
// 6. Insert all Photos & copy physical files
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
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();
|
||||
|
||||
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 +238,18 @@ namespace GerbilManagerWebAPI.Import
|
||||
public List<Contact> Contacts { get; set; } = new();
|
||||
public List<Litter> Litters { 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; }
|
||||
}
|
||||
}
|
||||
|
||||
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,12 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsBreeder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReceiver")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
@@ -794,6 +800,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("ImportSource")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsCastrated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool?>("IsDeaf")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -830,6 +839,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("ReceiverContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("SpottingType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -15,5 +15,8 @@ namespace GerbilManagerWebAPI.Models
|
||||
public string? Phone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public bool IsBreeder { get; set; }
|
||||
public bool IsReceiver { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
|
||||
public string? Genotype { get; set; }
|
||||
|
||||
public string? SpottingType { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// 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
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
public bool IsCastrated { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
|
||||
126
README.md
126
README.md
@@ -1,31 +1,95 @@
|
||||
# GerbilManager
|
||||
|
||||
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
|
||||
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
|
||||
|
||||
# Requirements
|
||||
Install ef:
|
||||
|
||||
dotnet tool install --global dotnet-ef
|
||||
|
||||
# Used cmd´s
|
||||
## Start containers
|
||||
docker compose up
|
||||
## Create the initial database
|
||||
dotnet ef database update
|
||||
## Create an migration script
|
||||
dotnet ef migrations add <name>
|
||||
|
||||
## Docker
|
||||
### force compose rebuild
|
||||
docker-compose build --no-cache
|
||||
|
||||
## Frontend (gerbil-manager-web)
|
||||
cd gerbil-manager-web
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
|
||||
TODO:
|
||||
- Backup docker volume database
|
||||
- run "dotnet ef database update" if no database exist.
|
||||
# GerbilManager
|
||||
|
||||
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
|
||||
|
||||
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
|
||||
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
|
||||
- `tools/import/` — Python import pipeline scripts and database schemas
|
||||
|
||||
## Requirements
|
||||
To manage database migrations, install the EF Core CLI tool globally:
|
||||
```bash
|
||||
dotnet tool install --global dotnet-ef
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
|
||||
```bash
|
||||
dotnet run --project GerbilManager.AppHost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📥 Data Import & Normalization Pipeline
|
||||
|
||||
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
|
||||
|
||||
### 1. Structure of the Pipeline
|
||||
|
||||
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
|
||||
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
|
||||
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
|
||||
|
||||
### 2. How to Run the Import
|
||||
|
||||
#### Automatic Way (All-in-One)
|
||||
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
|
||||
```
|
||||
|
||||
#### Manual Way
|
||||
1. **Recompile the import data**:
|
||||
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
|
||||
```bash
|
||||
python tools/import/merge_and_resolve.py
|
||||
```
|
||||
2. **Ingest into the running database**:
|
||||
Ensure the application is running, then send an HTTP `POST` request to the API:
|
||||
```bash
|
||||
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contact Deduplication Workflow
|
||||
|
||||
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
|
||||
|
||||
### 1. Search for Duplicate Contacts
|
||||
You can run duplicate checks using two different similarity parameter models:
|
||||
* **Round 1 (Clever matching on first 4 characters)**:
|
||||
```bash
|
||||
python tools/import/find_duplicates_v5.py
|
||||
```
|
||||
Creates: `tools/import/output/potential_duplicates_decision.md`
|
||||
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
|
||||
```bash
|
||||
python tools/import/find_duplicates_round2.py
|
||||
```
|
||||
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
|
||||
|
||||
### 2. Fill out the Checklist
|
||||
Open the generated markdown checklist file and edit the checkboxes:
|
||||
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
|
||||
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
|
||||
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
|
||||
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
|
||||
|
||||
*Example:*
|
||||
```markdown
|
||||
- [x] Max Mustermann <-- canonical name
|
||||
- [ ] Max M. <-- merges into Max Mustermann
|
||||
- [o] Moritz Mustermann <-- remains a separate standalone contact
|
||||
```
|
||||
|
||||
### 3. Apply the Decisions
|
||||
Once you have saved your decisions in the checklist file, run the parser:
|
||||
```bash
|
||||
python tools/import/parse_decisions.py
|
||||
```
|
||||
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
|
||||
Run the compilation pipeline again to produce the final deduplicated import JSON:
|
||||
```bash
|
||||
python tools/import/merge_and_resolve.py
|
||||
```
|
||||
@@ -7,9 +7,6 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
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
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
@@ -18,7 +15,8 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
||||
await expect(genotypeInput).toHaveValue('')
|
||||
|
||||
// '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')
|
||||
})
|
||||
|
||||
@@ -26,54 +24,47 @@ test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) =>
|
||||
skipUnlessMock()
|
||||
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
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
|
||||
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 }) => {
|
||||
skipUnlessMock()
|
||||
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
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
|
||||
await farbschlagSelect.selectOption({ label: 'Agouti' })
|
||||
await expect(farbschlagSelect).not.toHaveValue('')
|
||||
await page.locator('.color-picker-trigger').click()
|
||||
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
|
||||
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
|
||||
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 }) => {
|
||||
skipUnlessMock()
|
||||
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
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
await farbschlagSelect.selectOption({ label: 'Blau' })
|
||||
await expect(farbschlagSelect).not.toHaveValue('')
|
||||
await page.locator('.color-picker-trigger').click()
|
||||
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.
|
||||
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 —/)
|
||||
})
|
||||
|
||||
@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
|
||||
|
||||
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
||||
if (!orderBy) return rows
|
||||
const [field, dir] = orderBy.split(/\s+/)
|
||||
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
|
||||
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
|
||||
// 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) => {
|
||||
const av = String(a[field] ?? '')
|
||||
const bv = String(b[field] ?? '')
|
||||
return av < bv ? -sign : av > bv ? sign : 0
|
||||
for (const { field, sign } of keys) {
|
||||
const av = String(a[field] ?? '')
|
||||
const bv = String(b[field] ?? '')
|
||||
if (av < bv) return -sign
|
||||
if (av > bv) return sign
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,10 @@ export function seedDb(): MockDb {
|
||||
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||
{ ...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('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
|
||||
// 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.
|
||||
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-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
|
||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
|
||||
{ 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, isBreeder: false, isReceiver: true },
|
||||
{ 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 = [
|
||||
|
||||
@@ -46,7 +46,7 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
||||
const name = uniqueName('Tier')
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
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', {
|
||||
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 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()
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
|
||||
|
||||
const name = uniqueName('Testmaus')
|
||||
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()
|
||||
|
||||
// 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)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
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
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
@@ -189,6 +189,6 @@ test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTER
|
||||
skipUnlessMock()
|
||||
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-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()
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', asy
|
||||
|
||||
const name = uniqueName('Jungtier')
|
||||
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 expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
|
||||
import StammbaumPage from './pages/StammbaumPage'
|
||||
import StatistikPage from './pages/StatistikPage'
|
||||
import HilfePage from './pages/HilfePage'
|
||||
import FarbkatalogPage from './pages/FarbkatalogPage'
|
||||
import VertraegeListPage from './pages/VertraegeListPage'
|
||||
import VertragWizardPage from './pages/VertragWizardPage'
|
||||
import EinstellungenPage from './pages/EinstellungenPage'
|
||||
@@ -70,6 +71,7 @@ export default function App() {
|
||||
<Route path="abgabe" element={<AbgabePage />} />
|
||||
<Route path="statistik" element={<StatistikPage />} />
|
||||
<Route path="hilfe" element={<HilfePage />} />
|
||||
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
||||
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
||||
<Route path="vertraege">
|
||||
<Route index element={<VertraegeListPage />} />
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface CreateContact {
|
||||
phone?: string | null
|
||||
address?: string | null
|
||||
notes?: string | null
|
||||
isBreeder?: boolean
|
||||
isReceiver?: boolean
|
||||
}
|
||||
|
||||
/** Payload for PUT /contacts/{id}. */
|
||||
|
||||
@@ -22,13 +22,15 @@ export async function listEnclosures(): Promise<Enclosure[]> {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export async function listLitters(): Promise<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
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface Gerbil {
|
||||
isDeaf?: boolean | null
|
||||
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
|
||||
profilePhotoUrl?: string | null
|
||||
spottingType?: string | null
|
||||
isCastrated?: boolean
|
||||
}
|
||||
|
||||
/** Payload for POST /gerbils. */
|
||||
@@ -83,6 +85,8 @@ export interface CreateGerbil {
|
||||
characterNote?: string | null
|
||||
isResident?: boolean
|
||||
isDeaf?: boolean | null
|
||||
spottingType?: string | null
|
||||
isCastrated?: boolean
|
||||
}
|
||||
|
||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||
@@ -108,6 +112,8 @@ export interface Contact {
|
||||
phone: string | null
|
||||
address: string | null
|
||||
notes: string | null
|
||||
isBreeder: boolean
|
||||
isReceiver: boolean
|
||||
}
|
||||
|
||||
export interface Litter {
|
||||
|
||||
@@ -31,6 +31,7 @@ const SECONDARY: NavItem[] = [
|
||||
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
||||
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
|
||||
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
|
||||
{ to: '/farbkatalog', label: de.nav.colorCatalog, icon: '🎨' },
|
||||
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
||||
]
|
||||
|
||||
|
||||
156
gerbil-manager-web/src/components/ColorVarietyPicker.css
Normal file
156
gerbil-manager-web/src/components/ColorVarietyPicker.css
Normal 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;
|
||||
}
|
||||
116
gerbil-manager-web/src/components/ColorVarietyPicker.tsx
Normal file
116
gerbil-manager-web/src/components/ColorVarietyPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -65,3 +65,54 @@
|
||||
color: #d97706;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -73,10 +73,15 @@ export function useInfiniteList<T>(
|
||||
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(() => {
|
||||
let cancelled = false
|
||||
const page = req.page
|
||||
const id = `${req.nonce}:${req.key}#${page}`
|
||||
inFlightRef.current = true
|
||||
loaderRef
|
||||
.current(page)
|
||||
.then((res) => {
|
||||
@@ -92,6 +97,9 @@ export function useInfiniteList<T>(
|
||||
.catch((err) => {
|
||||
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) inFlightRef.current = false
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -109,8 +117,35 @@ export function useInfiniteList<T>(
|
||||
const error = inFlight ? null : sameQuery ? resolved.error : null
|
||||
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 })), [])
|
||||
|
||||
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires an IntersectionObserver to a sentinel element that triggers loadMore() as
|
||||
* it scrolls into view. Render the returned ref on a small element shown only while
|
||||
* `list.hasMore`, e.g. `{list.hasMore && <div ref={sentinelRef} />}`. Re-subscribes
|
||||
* when the load state changes so it keeps paging while the sentinel stays in view.
|
||||
*/
|
||||
export function useInfiniteSentinel<T>(list: InfiniteListState<T>) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const { hasMore, loading, loadingMore, loadMore } = list
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) loadMore()
|
||||
},
|
||||
{ rootMargin: '300px 0px' },
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [hasMore, loading, loadingMore, loadMore])
|
||||
return ref
|
||||
}
|
||||
|
||||
@@ -796,10 +796,149 @@ textarea {
|
||||
}
|
||||
|
||||
/* Profilfoto im Detail-Kopf */
|
||||
.profile-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-border);
|
||||
.profile-header-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
syncRequests,
|
||||
type RequestStatus,
|
||||
} from '../api/requests'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useMutation } from '../hooks/useApi'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
import { formatDateTime } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
import './anfragen.css'
|
||||
@@ -31,19 +32,20 @@ export function StatusBadge({ status }: { status: RequestStatus }) {
|
||||
export default function AnfragenPage() {
|
||||
const t = de.pages.anfragen
|
||||
const [status, setStatus] = useState<RequestStatus | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const requests = useApi(
|
||||
() =>
|
||||
const requests = useInfiniteList(
|
||||
(page) =>
|
||||
listRequests({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
orderBy: 'receivedAt desc',
|
||||
orderBy: 'receivedAt desc,id',
|
||||
filter: status === '' ? undefined : `status==${status}`,
|
||||
}),
|
||||
[page, status],
|
||||
`${status}`,
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(requests)
|
||||
const { items, total, loading } = requests
|
||||
|
||||
const sync = useMutation(() => syncRequests())
|
||||
async function onSync() {
|
||||
@@ -63,7 +65,7 @@ export default function AnfragenPage() {
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
|
||||
{!loading && <p className="muted">{t.countText(total)}</p>}
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
||||
@@ -78,16 +80,13 @@ export default function AnfragenPage() {
|
||||
{/* Status-Filter */}
|
||||
<FilterPanel
|
||||
activeCount={status !== '' ? 1 : 0}
|
||||
onReset={() => { setStatus(''); setPage(1) }}
|
||||
onReset={() => setStatus('')}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t.detail.statusLabel}</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as RequestStatus | '')
|
||||
setPage(1)
|
||||
}}
|
||||
onChange={(e) => setStatus(e.target.value as RequestStatus | '')}
|
||||
>
|
||||
<option value="">{t.filterAll}</option>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
@@ -99,7 +98,7 @@ export default function AnfragenPage() {
|
||||
</label>
|
||||
</FilterPanel>
|
||||
|
||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{loading && <p className="muted">{de.common.loading}</p>}
|
||||
{requests.error && (
|
||||
<div className="alert alert--error">
|
||||
<span>{requests.error}</span>
|
||||
@@ -109,13 +108,13 @@ export default function AnfragenPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length === 0 && (
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length > 0 && (
|
||||
{items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{requests.data.items.map((r) => (
|
||||
{items.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
||||
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
||||
@@ -130,24 +129,12 @@ export default function AnfragenPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.totalCount > PAGE_SIZE && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span>
|
||||
{de.common.page} {page} {de.common.of}{' '}
|
||||
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||
{requests.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||
{requests.loadingMore && (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||
{de.common.loading}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
|
||||
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listEnclosuresPaged } from '../api/enclosures'
|
||||
import { condition, type GridifyQuery } from '../api/gridify'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { condition } from '../api/gridify'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function BeckenPage() {
|
||||
const t = de.pages.becken
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const filter = search.trim()
|
||||
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
||||
: undefined
|
||||
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
|
||||
|
||||
const enclosures = useApi(() => listEnclosuresPaged(query), [filter, page])
|
||||
|
||||
const total = enclosures.data?.totalCount ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const items = enclosures.data?.items ?? []
|
||||
// Infinite scroll; trailing `id` keeps OFFSET pagination stable (no repeats/omits).
|
||||
const list = useInfiniteList(
|
||||
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
`${filter ?? ''}`,
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(list)
|
||||
const { items, total, loading } = list
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{enclosures.data && (
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
{total} {t.countLabel}
|
||||
</p>
|
||||
@@ -46,27 +45,22 @@ export default function BeckenPage() {
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enclosures.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{enclosures.error && (
|
||||
{loading && <p className="muted">{de.common.loading}</p>}
|
||||
{list.error && (
|
||||
<div className="alert alert--error">
|
||||
<span>{enclosures.error}</span>
|
||||
<button type="button" className="btn" onClick={enclosures.reload}>
|
||||
<span>{list.error}</span>
|
||||
<button type="button" className="btn" onClick={list.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enclosures.loading && !enclosures.error && items.length === 0 && (
|
||||
<p className="muted">{t.empty}</p>
|
||||
)}
|
||||
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
@@ -81,28 +75,12 @@ export default function BeckenPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{de.common.page} {page} {de.common.of} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||
{list.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||
{list.loadingMore && (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||
{de.common.loading}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
81
gerbil-manager-web/src/pages/FarbkatalogPage.tsx
Normal file
81
gerbil-manager-web/src/pages/FarbkatalogPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export default function GerbilDetailPage() {
|
||||
const [charTraits, setCharTraits] = useState<string[]>([])
|
||||
const [charNote, setCharNote] = useState('')
|
||||
const [charInit, setCharInit] = useState<string | null>(null)
|
||||
const [isCharacterExpanded, setIsCharacterExpanded] = useState(true)
|
||||
const saveCharacter = useMutation(() =>
|
||||
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
|
||||
)
|
||||
@@ -78,6 +79,12 @@ export default function GerbilDetailPage() {
|
||||
[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.error || !gerbil.data) {
|
||||
return (
|
||||
@@ -91,6 +98,11 @@ export default function GerbilDetailPage() {
|
||||
}
|
||||
|
||||
const g = gerbil.data
|
||||
const showEnclosure = g.status !== 'Deceased' && (
|
||||
g.status !== 'GivenAway' ||
|
||||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
|
||||
)
|
||||
|
||||
const geno = describeGenotype(g.genotype)
|
||||
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||
@@ -100,20 +112,25 @@ export default function GerbilDetailPage() {
|
||||
setCharInit(g.id)
|
||||
setCharTraits(g.characterTraits ?? [])
|
||||
setCharNote(g.characterNote ?? '')
|
||||
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<div className="profile-header-summary">
|
||||
<GerbilProfilePhoto gerbilId={g.id} />
|
||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
<div className="profile-header-info">
|
||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
||||
<div className="profile-header-badges">
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<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">
|
||||
{t.detail.testMating}
|
||||
</Link>
|
||||
{g.status !== 'ForSale' && (
|
||||
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
@@ -152,7 +169,15 @@ export default function GerbilDetailPage() {
|
||||
|
||||
<h3>{t.detail.masterData}</h3>
|
||||
<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.dateOfBirth} value={formatDate(g.dateOfBirth)} />
|
||||
{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
|
||||
label={t.fields.litter}
|
||||
value={
|
||||
@@ -194,7 +230,9 @@ export default function GerbilDetailPage() {
|
||||
: (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} />
|
||||
</dl>
|
||||
|
||||
@@ -209,7 +247,7 @@ export default function GerbilDetailPage() {
|
||||
{geno.farbschlag}
|
||||
{storedColorName &&
|
||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
||||
storedColorName !== geno.farbschlag && (
|
||||
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||
<small className="mismatch-hint"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
)}
|
||||
</>
|
||||
@@ -220,27 +258,38 @@ export default function GerbilDetailPage() {
|
||||
<p className="muted">{t.detail.genotypeNotSet}</p>
|
||||
)}
|
||||
|
||||
<h3>{de.character.sectionTitle}</h3>
|
||||
<Charakterbogen
|
||||
traits={charTraits}
|
||||
note={charNote}
|
||||
onTraitsChange={setCharTraits}
|
||||
onNoteChange={setCharNote}
|
||||
/>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
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>
|
||||
<details
|
||||
className="charakter-details"
|
||||
open={isCharacterExpanded}
|
||||
onToggle={(e) => setIsCharacterExpanded(e.currentTarget.open)}
|
||||
>
|
||||
<summary className="charakter-summary">
|
||||
<h3>{de.character.sectionTitle}</h3>
|
||||
<span className="charakter-chevron" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div className="charakter-body">
|
||||
<Charakterbogen
|
||||
traits={charTraits}
|
||||
note={charNote}
|
||||
onTraitsChange={setCharTraits}
|
||||
onNoteChange={setCharNote}
|
||||
/>
|
||||
<div className="form-actions" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
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>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
|
||||
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 { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import ColorVarietyPicker from '../components/ColorVarietyPicker'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
@@ -29,11 +29,14 @@ interface FormState {
|
||||
notes: string
|
||||
isResident: boolean
|
||||
isDeaf: boolean | null
|
||||
spottingType: string
|
||||
isSchecke: boolean
|
||||
isCastrated: boolean
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
gender: '',
|
||||
gender: 'unknown',
|
||||
status: 'Breeding',
|
||||
dateOfBirth: '',
|
||||
dateOfDeath: '',
|
||||
@@ -49,6 +52,9 @@ const EMPTY: FormState = {
|
||||
notes: '',
|
||||
isResident: true,
|
||||
isDeaf: null,
|
||||
spottingType: '',
|
||||
isSchecke: false,
|
||||
isCastrated: false,
|
||||
}
|
||||
|
||||
function formFromGerbil(g: {
|
||||
@@ -69,7 +75,11 @@ function formFromGerbil(g: {
|
||||
notes: string | null
|
||||
isResident?: boolean | null
|
||||
isDeaf?: boolean | null
|
||||
spottingType?: string | null
|
||||
isCastrated?: boolean | null
|
||||
}): FormState {
|
||||
const hasSp = g.genotype?.includes('Sp') || false
|
||||
const hasSpottingType = Boolean(g.spottingType)
|
||||
return {
|
||||
name: g.name,
|
||||
gender: g.gender,
|
||||
@@ -88,6 +98,9 @@ function formFromGerbil(g: {
|
||||
notes: g.notes ?? '',
|
||||
isResident: g.isResident ?? true,
|
||||
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 {
|
||||
const next: Partial<Record<keyof FormState, string>> = {}
|
||||
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 (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
|
||||
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
|
||||
@@ -184,6 +199,8 @@ export default function GerbilFormPage() {
|
||||
notes: nn(form.notes),
|
||||
isResident: form.isResident,
|
||||
isDeaf: form.isDeaf,
|
||||
spottingType: nn(form.spottingType),
|
||||
isCastrated: form.isCastrated,
|
||||
}
|
||||
const result = await mutation.run(body)
|
||||
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>
|
||||
|
||||
const selectedColorName =
|
||||
(colorVarieties.data ?? []).find((cv) => cv.id === form.colorVarietyId)?.name ?? null
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||
@@ -229,22 +243,176 @@ export default function GerbilFormPage() {
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.gender} *</span>
|
||||
<select
|
||||
value={form.gender}
|
||||
onChange={(e) => set('gender', e.target.value as Gender | '')}
|
||||
aria-invalid={Boolean(errors.gender)}
|
||||
>
|
||||
<span>{t.fields.origin}</span>
|
||||
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
|
||||
<option value="">{t.form.none}</option>
|
||||
{GENDERS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{genderLabel(g)}
|
||||
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.gender && <small className="error-text">{errors.gender}</small>}
|
||||
</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 */}
|
||||
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
|
||||
<div className="field">
|
||||
@@ -269,16 +437,6 @@ export default function GerbilFormPage() {
|
||||
</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' && (
|
||||
<>
|
||||
<label className="field">
|
||||
@@ -315,31 +473,6 @@ export default function GerbilFormPage() {
|
||||
</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">
|
||||
<span>{t.fields.enclosure}</span>
|
||||
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
||||
@@ -364,27 +497,7 @@ export default function GerbilFormPage() {
|
||||
</select>
|
||||
</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">
|
||||
<span>{t.fields.receiver}</span>
|
||||
@@ -393,7 +506,7 @@ export default function GerbilFormPage() {
|
||||
onChange={(e) => set('receiverContactId', e.target.value)}
|
||||
>
|
||||
<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}>
|
||||
{c.name}
|
||||
</option>
|
||||
@@ -401,55 +514,16 @@ export default function GerbilFormPage() {
|
||||
</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.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.notes}</span>
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
</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}>
|
||||
<span>{t.form.isResidentLabel}</span>
|
||||
|
||||
Binary file not shown.
@@ -96,6 +96,17 @@ export default function KontaktDetailPage() {
|
||||
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
||||
|
||||
<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">
|
||||
<dt>{t.fields.email}</dt>
|
||||
<dd>{c.email ?? '—'}</dd>
|
||||
|
||||
@@ -12,9 +12,11 @@ interface FormState {
|
||||
phone: string
|
||||
address: 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. */
|
||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
@@ -40,6 +42,8 @@ export default function KontaktFormPage() {
|
||||
phone: existing.data.phone ?? '',
|
||||
address: existing.data.address ?? '',
|
||||
notes: existing.data.notes ?? '',
|
||||
isBreeder: existing.data.isBreeder,
|
||||
isReceiver: existing.data.isReceiver,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +67,8 @@ export default function KontaktFormPage() {
|
||||
phone: nn(form.phone),
|
||||
address: nn(form.address),
|
||||
notes: nn(form.notes),
|
||||
isBreeder: form.isBreeder,
|
||||
isReceiver: form.isReceiver,
|
||||
})
|
||||
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
||||
// 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>
|
||||
</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">
|
||||
<span>{t.fields.notes}</span>
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
|
||||
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { condition, type GridifyQuery } from '../api/gridify'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { condition } from '../api/gridify'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function KontaktePage() {
|
||||
const t = de.pages.kontakte
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const filter = search.trim()
|
||||
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
||||
: undefined
|
||||
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
|
||||
|
||||
const contacts = useApi(() => listContactsPaged(query), [filter, page])
|
||||
|
||||
const total = contacts.data?.totalCount ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const items = contacts.data?.items ?? []
|
||||
// Infinite scroll: one page at a time, accumulated. The trailing `id` makes the
|
||||
// ordering a total order so OFFSET pagination never repeats/omits a row.
|
||||
const list = useInfiniteList(
|
||||
(page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
`${filter ?? ''}`,
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(list)
|
||||
const { items, total, hasMore, loading, loadingMore } = list
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{contacts.data && (
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
{total} {t.countLabel}
|
||||
</p>
|
||||
@@ -46,34 +46,61 @@ export default function KontaktePage() {
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{contacts.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{contacts.error && (
|
||||
{loading && <p className="muted">{de.common.loading}</p>}
|
||||
{list.error && (
|
||||
<div className="alert alert--error">
|
||||
<span>{contacts.error}</span>
|
||||
<button type="button" className="btn" onClick={contacts.reload}>
|
||||
<span>{list.error}</span>
|
||||
<button type="button" className="btn" onClick={list.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!contacts.loading && !contacts.error && items.length === 0 && (
|
||||
<p className="muted">{t.empty}</p>
|
||||
)}
|
||||
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<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">
|
||||
{[c.phone, c.email].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
@@ -83,28 +110,12 @@ export default function KontaktePage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{de.common.page} {page} {de.common.of} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||
{hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||
{loadingMore && (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||
{de.common.loading}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
import { formatDate } from '../format/labels'
|
||||
import './vertragWizard.css'
|
||||
|
||||
@@ -16,12 +17,14 @@ function formatPrice(price: number): string {
|
||||
|
||||
export default function VertraegeListPage() {
|
||||
const t = de.pages.vertraege
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const contracts = useApi(
|
||||
() => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc' }),
|
||||
[page],
|
||||
const contracts = useInfiniteList(
|
||||
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
|
||||
'contracts',
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(contracts)
|
||||
const { items, total: totalCount, loading } = contracts
|
||||
|
||||
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||
const contactName = useMemo(
|
||||
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
|
||||
@@ -35,8 +38,8 @@ export default function VertraegeListPage() {
|
||||
if (result.ok) contracts.reload()
|
||||
}
|
||||
|
||||
if (contracts.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (contracts.error || !contracts.data) {
|
||||
if (loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (contracts.error) {
|
||||
return (
|
||||
<section className="page">
|
||||
<h2>{t.title}</h2>
|
||||
@@ -50,9 +53,6 @@ export default function VertraegeListPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const { items, totalCount } = contracts.data
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
@@ -98,23 +98,12 @@ export default function VertraegeListPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span>
|
||||
{de.common.page} {page} {de.common.of} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||
{contracts.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||
{contracts.loadingMore && (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||
{de.common.loading}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function VertragWizardPage() {
|
||||
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
|
||||
|
||||
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 needle = contactSearch.trim().toLowerCase()
|
||||
return needle === ''
|
||||
@@ -62,6 +62,8 @@ export default function VertragWizardPage() {
|
||||
email: newContact.email.trim() || null,
|
||||
phone: newContact.phone.trim() || null,
|
||||
address: newContact.address.trim() || null,
|
||||
isReceiver: true,
|
||||
isBreeder: false,
|
||||
}),
|
||||
)
|
||||
async function onCreateContact() {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
||||
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
|
||||
import type { Litter } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
import { formatDate } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
|
||||
@@ -13,11 +14,12 @@ const PAGE_SIZE = 20
|
||||
|
||||
type Tab = 'litters' | 'pairs'
|
||||
type SortKey = 'dateDesc' | 'dateAsc'
|
||||
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc: 'date' }
|
||||
// Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
|
||||
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
|
||||
|
||||
function yearOptions(): number[] {
|
||||
const current = new Date().getFullYear()
|
||||
return Array.from({ length: 16 }, (_, i) => current - i)
|
||||
return Array.from({ length: 30 }, (_, i) => current - i)
|
||||
}
|
||||
|
||||
interface Pair {
|
||||
@@ -47,9 +49,9 @@ function derivePairs(litters: Litter[]): Pair[] {
|
||||
export default function WuerfeListPage() {
|
||||
const t = de.pages.litters
|
||||
const [tab, setTab] = useState<Tab>('litters')
|
||||
const [search, setSearch] = useState('')
|
||||
const [year, setYear] = useState('')
|
||||
const [sort, setSort] = useState<SortKey>('dateDesc')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Parent names (shared by both tabs).
|
||||
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||
@@ -62,12 +64,16 @@ export default function WuerfeListPage() {
|
||||
|
||||
// Litters tab (paged, filtered).
|
||||
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}-12-31` }),
|
||||
)
|
||||
const orderBy = SORT_ORDER_BY[sort]
|
||||
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
||||
const litters = useApi(() => listLitters(query), [filter, orderBy, page])
|
||||
const litters = useInfiniteList(
|
||||
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
|
||||
`${filter}|${orderBy}`,
|
||||
)
|
||||
const littersSentinelRef = useInfiniteSentinel(litters)
|
||||
|
||||
// Pairs tab: all litters, grouped client-side.
|
||||
const allLitters = useApi(
|
||||
@@ -76,9 +82,17 @@ export default function WuerfeListPage() {
|
||||
)
|
||||
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
|
||||
|
||||
const total = litters.data?.totalCount ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const items = litters.data?.items ?? []
|
||||
// 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 items = litters.items
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
@@ -113,17 +127,28 @@ export default function WuerfeListPage() {
|
||||
{tab === 'litters' && (
|
||||
<>
|
||||
<FilterPanel
|
||||
activeCount={year !== '' ? 1 : 0}
|
||||
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
||||
searchField={
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Wurf suchen"
|
||||
/>
|
||||
}
|
||||
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
|
||||
onReset={() => {
|
||||
setSearch('')
|
||||
setYear('')
|
||||
setSort('dateDesc')
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t.filterYear}</span>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => {
|
||||
setYear(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
>
|
||||
<option value="">{t.allYears}</option>
|
||||
{yearOptions().map((y) => (
|
||||
@@ -174,39 +199,45 @@ export default function WuerfeListPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="pager" aria-label={de.common.pageNav}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{de.common.page} {page} {de.common.of} {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||
{litters.hasMore && (
|
||||
<div ref={littersSentinelRef} aria-hidden="true" style={{ height: 1 }} />
|
||||
)}
|
||||
{litters.loadingMore && (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||
{de.common.loading}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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 && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
|
||||
{pairs.length > 0 && (
|
||||
{!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
|
||||
{filteredPairs.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{pairs.map((p) => (
|
||||
{filteredPairs.map((p) => (
|
||||
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">
|
||||
{parentName(p.fatherId)} × {parentName(p.motherId)}
|
||||
|
||||
142
gerbil-manager-web/src/pages/farbkatalog.css
Normal file
142
gerbil-manager-web/src/pages/farbkatalog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,42 @@
|
||||
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 ----------
|
||||
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
|
||||
|
||||
@@ -34,6 +34,7 @@ export const de = {
|
||||
help: 'Hilfe',
|
||||
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
|
||||
website: 'Webseite',
|
||||
colorCatalog: 'Farbkatalog',
|
||||
},
|
||||
pages: {
|
||||
gerbils: {
|
||||
@@ -46,6 +47,8 @@ export const de = {
|
||||
searchPlaceholder: 'Name suchen …',
|
||||
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
|
||||
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
|
||||
externalBadge: 'Extern',
|
||||
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',
|
||||
receiver: 'Abnehmer',
|
||||
genotype: 'Genotyp',
|
||||
isCastrated: 'Kastrat',
|
||||
notes: 'Notizen',
|
||||
spottingType: 'Scheckungsart',
|
||||
},
|
||||
// Detailseite
|
||||
detail: {
|
||||
@@ -128,6 +133,8 @@ export const de = {
|
||||
isDeafUnknown: 'Unbekannt',
|
||||
isDeafYes: 'Ja',
|
||||
isDeafNo: 'Nein',
|
||||
isScheckeLabel: 'Ist eine Schecke',
|
||||
colorSectionTitle: 'Farbe & Genetik',
|
||||
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
|
||||
statusDerivedHint: {
|
||||
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
|
||||
@@ -158,6 +165,17 @@ export const de = {
|
||||
GivenAway: 'Abgegeben',
|
||||
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 ──
|
||||
litters: {
|
||||
@@ -170,6 +188,7 @@ export const de = {
|
||||
newButton: 'Neuer Wurf',
|
||||
empty: 'Keine Würfe gefunden.',
|
||||
countLabel: 'Würfe',
|
||||
searchPlaceholder: 'Wurf suchen …',
|
||||
filterYear: 'Jahr',
|
||||
allYears: 'Alle Jahre',
|
||||
sort: {
|
||||
@@ -239,6 +258,12 @@ export const de = {
|
||||
title: 'Züchter',
|
||||
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: {
|
||||
title: 'Seite nicht gefunden',
|
||||
message: 'Die angeforderte Seite existiert nicht.',
|
||||
@@ -570,6 +595,8 @@ export const de = {
|
||||
phone: 'Telefon',
|
||||
address: 'Adresse',
|
||||
notes: 'Notizen',
|
||||
isBreeder: 'Ist Züchter (Zucht)',
|
||||
isReceiver: 'Ist Abnehmer (Käufer)',
|
||||
},
|
||||
linked: {
|
||||
title: 'Verknüpfte Tiere',
|
||||
|
||||
@@ -213,6 +213,35 @@
|
||||
"genotype": "AA CC DD ee Gg pp Spsp",
|
||||
"farbschlag": "Goldfuchs Starkschecke",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,19 +35,20 @@ DEATH = re.compile(r"\+\s?(\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2})|\d{4})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers ----
|
||||
def gen_of(colnum):
|
||||
def gen_of(colnum, offset=0):
|
||||
"""Map a column number to a generation band (0=proband ... 5=deepest)."""
|
||||
if colnum <= 6:
|
||||
return 0 # E band (proband / "Kids")
|
||||
if colnum <= 9:
|
||||
return 1 # H band (parents)
|
||||
if colnum <= 12:
|
||||
return 2 # K band (grandparents)
|
||||
if colnum <= 15:
|
||||
return 3 # N band (great-grandparents)
|
||||
if colnum <= 17:
|
||||
return 4 # Q band (gg-grandparents)
|
||||
return 5 # R/S band (name-pairs)
|
||||
effective_col = colnum - offset
|
||||
if effective_col <= 3:
|
||||
return 0 # Column B (2) -> proband
|
||||
if effective_col <= 6:
|
||||
return 1 # Column E (5) -> parents
|
||||
if effective_col <= 9:
|
||||
return 2 # Column H (8) -> grandparents
|
||||
if effective_col <= 12:
|
||||
return 3 # Column K (11) -> great-grandparents
|
||||
if effective_col <= 15:
|
||||
return 4 # Column N (14) -> gg-grandparents
|
||||
return 5 # Column Q (17) or deeper -> ggg-grandparents
|
||||
|
||||
|
||||
def norm_name(name):
|
||||
@@ -189,6 +190,9 @@ def extract_stammbaum(path):
|
||||
cells = xu.read_cells(z, sheets[0], ss)
|
||||
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
|
||||
by_col = {}
|
||||
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
|
||||
# (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_band = gen_of(c) >= 2
|
||||
deep_band = gen_of(c, col_offset) >= 2
|
||||
for rr in range(r + 1, r + 4):
|
||||
cell = cells.get((c, rr))
|
||||
if not cell:
|
||||
@@ -269,7 +273,7 @@ def extract_stammbaum(path):
|
||||
"parentRefs": [],
|
||||
"photos": [],
|
||||
"sourceFiles": [fname],
|
||||
"_gen": gen_of(c),
|
||||
"_gen": gen_of(c, col_offset),
|
||||
"_col": c,
|
||||
"_row": r,
|
||||
"_file": fname,
|
||||
@@ -280,7 +284,7 @@ def extract_stammbaum(path):
|
||||
for (c, r), t in cells.items():
|
||||
if (c, r) in used:
|
||||
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(" & "):
|
||||
part = clean_name(part)
|
||||
if part:
|
||||
@@ -291,7 +295,7 @@ def extract_stammbaum(path):
|
||||
"genotype": gt.parse(""), "deaf": None, "tags": [],
|
||||
"breeder": "", "zucht": zraw,
|
||||
"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),
|
||||
})
|
||||
|
||||
@@ -337,8 +341,9 @@ def _attach_photos(z, sheets, animals, fname):
|
||||
for a in animals:
|
||||
by_gen.setdefault(a["_gen"], []).append(a)
|
||||
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):
|
||||
g = gen_of(col)
|
||||
g = gen_of(col, col_offset)
|
||||
cands = by_gen.get(g, [])
|
||||
if not cands:
|
||||
# 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)}")
|
||||
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 = []
|
||||
if os.path.isfile(args.wurfchronik):
|
||||
litters = extract_wurfchronik(args.wurfchronik)
|
||||
|
||||
@@ -68,11 +68,18 @@ def _sls_alleles(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("()[]")
|
||||
if t == "dea" or t.lower() == "taub":
|
||||
t_lower = t.lower()
|
||||
if "taub" in t_lower:
|
||||
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 None
|
||||
|
||||
|
||||
1669
tools/import/merge_and_resolve.py
Normal file
1669
tools/import/merge_and_resolve.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,15 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
||||
|
||||
## Überblick
|
||||
|
||||
- Rohe Tier-Einträge aus den Stammbäumen: **2451**
|
||||
- Nach Zusammenführung (eindeutige Tiere): **1007**
|
||||
- davon mit Geburtsdatum: 682
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 461
|
||||
- Konflikte zur Klärung: **7**
|
||||
- Rohe Tier-Einträge aus den Stammbäumen: **2449**
|
||||
- Nach Zusammenführung (eindeutige Tiere): **1006**
|
||||
- davon mit Geburtsdatum: 681
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
|
||||
- Konflikte zur Klärung: **2**
|
||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
|
||||
- Fotos zugeordnet: **417**
|
||||
- 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)
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
@@ -160,7 +155,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
||||
|---|---|---|
|
||||
| `/+` | 7 | ? |
|
||||
| `[meliert]` | 4 | ? |
|
||||
| `[Dea/dea]` | 4 | ? |
|
||||
| `(Kragen)` | 3 | ? |
|
||||
| `/` | 3 | ? |
|
||||
| `-g` | 2 | ? |
|
||||
@@ -168,7 +162,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
||||
| `Tumor` | 2 | ? |
|
||||
| `!` | 2 | ? |
|
||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||
| `[dea/*]` | 2 | ? |
|
||||
| `(KW` | 2 | ? |
|
||||
| `Cc[]` | 1 | ? |
|
||||
| `[WFNZ-Maroon]` | 1 | ? |
|
||||
@@ -176,13 +169,15 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
||||
| `[meliert]-[DP]` | 1 | ? |
|
||||
| `!Knickschwanz-Gen!` | 1 | ? |
|
||||
| `-09.09.2018` | 1 | ? |
|
||||
| `[dea/dea]` | 1 | ? |
|
||||
| `[DP?]` | 1 | ? |
|
||||
| `/+Dezember'2014` | 1 | ? |
|
||||
| `(Ansatz)` | 1 | ? |
|
||||
| `[WFNZ][dea/*]` | 1 | ? |
|
||||
| `-psp` | 1 | ? |
|
||||
| `G(G)` | 1 | ? |
|
||||
| `!Niereninsuffizienz!` | 1 | ? |
|
||||
| `+09.07.2017` | 1 | ? |
|
||||
| `+2018` | 1 | ? |
|
||||
| `AAA` | 1 | ? |
|
||||
|
||||
## Wurfchronik — Datenqualitäts-Hinweise
|
||||
|
||||
|
||||
@@ -344,13 +344,28 @@ def main():
|
||||
# Attach Photos
|
||||
for idx, photo_rel in enumerate(a.get("photos", [])):
|
||||
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({
|
||||
"Id": photo_guid,
|
||||
"GerbilId": a_guid,
|
||||
"FileName": os.path.basename(photo_rel),
|
||||
"SortOrder": idx
|
||||
"FileName": f"{fn_guid}{ext}",
|
||||
"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
|
||||
resolved_data = {
|
||||
"Contacts": resolved_contacts,
|
||||
|
||||
65
tools/import/run_app_and_import.ps1
Normal file
65
tools/import/run_app_and_import.ps1
Normal 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
|
||||
}
|
||||
@@ -68,8 +68,8 @@ finally:
|
||||
except OSError: pass
|
||||
|
||||
gen = e.gen_of
|
||||
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
|
||||
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 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 K, N, Q", gen(11) == 3 and gen(14) == 4 and gen(17) == 5)
|
||||
|
||||
# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
|
||||
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")
|
||||
|
||||
Reference in New Issue
Block a user