Compare commits
60 Commits
feature/st
...
bddbaf59d3
| Author | SHA1 | Date | |
|---|---|---|---|
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 | |||
| 5732398e60 | |||
| 0185446bfe | |||
| 04d189bd2f | |||
| 7d8166c555 | |||
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| ddc343a96b | |||
| a26bc4d75e | |||
| d4dfe6c3ac | |||
| 40f3ce250d | |||
| ffbbc0dfad | |||
| 05cb1af39e | |||
| 78f87f0d44 | |||
| 7f6855be62 | |||
| 29e9c4cd64 | |||
| 8eddb83c99 | |||
| 1a5f3218fb | |||
| 2d54cb901f | |||
| 03f8e4de90 | |||
| 70803efa1f | |||
| 25af10a736 | |||
| ade40eef5e | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| c0ecf2022d | |||
| 2198c33898 | |||
| 04971bf3bb | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| c452b69dd6 | |||
| f20a42a94b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -137,3 +137,6 @@ GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||
GerbilManagerWebAPI/.data-protection-keys/
|
||||
|
||||
# node_modules — never commit, installed locally per npm install
|
||||
node_modules/
|
||||
|
||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||
public class CmsPublishTests
|
||||
{
|
||||
private static string TempRoot() =>
|
||||
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
var files = new Dictionary<string, string>
|
||||
{
|
||||
["index.html"] = "<html>start</html>",
|
||||
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||
["assets/site.css"] = "body {}",
|
||||
};
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||
Assert.Equal("<html>start</html>",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||
Assert.Equal("version-2", content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_mehrfach_ohne_Fehler()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||
}
|
||||
|
||||
Assert.Equal("v3",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(
|
||||
Path.Combine(root, "live", "index.html"),
|
||||
System.Text.Encoding.UTF8);
|
||||
Assert.Equal(german, content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
|
||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||
// a non-ForSale gerbil must NOT appear
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
|
||||
@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Krümel",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||
LitterId = litter.Id,
|
||||
EnclosureId = enclosure.Id,
|
||||
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
|
||||
var row = lines[1];
|
||||
Assert.Contains("Krümel", row);
|
||||
Assert.Contains("Weiblich", row);
|
||||
Assert.Contains("Aktiv", row);
|
||||
Assert.Contains("Zucht", row);
|
||||
Assert.Contains("12.03.2025", row);
|
||||
Assert.Contains("Großbecken", row);
|
||||
Assert.Contains("Wurf K", row);
|
||||
|
||||
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
|
||||
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public GerbilProfilePhotoTests(ApiFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private async Task<Guid> CreateGerbil(string name)
|
||||
{
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
foreach (var (fn, so) in photos)
|
||||
db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbilId,
|
||||
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
|
||||
{
|
||||
var id = await CreateGerbil("NoPhoto");
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
|
||||
{
|
||||
var id = await CreateGerbil("PhotoGerbil");
|
||||
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
|
||||
{
|
||||
var id = await CreateGerbil("ListPhoto");
|
||||
InsertPhotos(id, ("list-photo.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync("/gerbils");
|
||||
var root = JsonDocument.Parse(json).RootElement;
|
||||
var items = root.GetProperty("items").EnumerateArray().ToList();
|
||||
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
|
||||
Assert.NotEqual(default, gerbil);
|
||||
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
|
||||
}
|
||||
}
|
||||
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
|
||||
public class GerbilSearchTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
|
||||
JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
originBreeder,
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
|
||||
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
|
||||
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
|
||||
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
|
||||
Assert.Equal(expected, GerbilSearch.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_filter_matches_across_separators()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
|
||||
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
|
||||
// The client normalizes the user's term the same way the column is normalized.
|
||||
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
|
||||
Assert.Contains("Clan-Kleine-Chaoten", json);
|
||||
|
||||
// a term that doesn't normalize-match must NOT return it
|
||||
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
|
||||
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
|
||||
{
|
||||
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
|
||||
|
||||
// distinct-values dropdown source
|
||||
var breeders = await _client.GetStringAsync("/gerbils/breeders");
|
||||
Assert.Contains("Zucht der kleinen Chaoten", breeders);
|
||||
|
||||
// Gridify filter on the field
|
||||
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
|
||||
Assert.Contains("Herkunftstier", filtered);
|
||||
}
|
||||
}
|
||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
|
||||
public class GerbilStatusTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 6, 7);
|
||||
|
||||
// 1) DateOfDeath → Deceased, always overrides user input
|
||||
[Fact]
|
||||
public void DateOfDeath_set_returns_Deceased()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// DateOfDeath wins even when also Abgegeben
|
||||
[Fact]
|
||||
public void DateOfDeath_wins_over_Abgegeben()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// 2) Abgegeben without death date → GivenAway
|
||||
[Fact]
|
||||
public void ReceiverContact_set_returns_GivenAway()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
|
||||
[Fact]
|
||||
public void OlderThan7y_without_death_returns_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
|
||||
[Fact]
|
||||
public void ExactlyMaxAge_is_not_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
|
||||
// (threshold = "older than" so >= means Deceased)
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// One day before threshold is alive
|
||||
[Fact]
|
||||
public void OneDayBeforeMaxAge_is_alive()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
|
||||
[Fact]
|
||||
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
|
||||
{
|
||||
var dob = Today.AddYears(-8);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 4) Default → Breeding
|
||||
[Fact]
|
||||
public void No_special_condition_returns_Breeding()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 4a) User sets Pet → kept
|
||||
[Fact]
|
||||
public void User_Pet_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Pet, status);
|
||||
}
|
||||
|
||||
// 4b) User sets ForSale → kept
|
||||
[Fact]
|
||||
public void User_ForSale_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.ForSale, status);
|
||||
}
|
||||
|
||||
// DateOfBirth null → age rule does not fire
|
||||
[Fact]
|
||||
public void Null_DateOfBirth_does_not_trigger_age_rule()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
181
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
181
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
public class IngestResolvedServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
|
||||
public IngestResolvedServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
|
||||
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
var photoId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = contactId,
|
||||
Name = "Test Breeder",
|
||||
ZuchtName = "Test Zucht",
|
||||
City = "Test City",
|
||||
Email = "test@example.com",
|
||||
Homepage = "",
|
||||
Phone = "",
|
||||
Address = ""
|
||||
}
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = litterId,
|
||||
Name = "Wurf A",
|
||||
Date = "2026-01-01",
|
||||
TotalBorn = 5,
|
||||
DeathsWithin8Weeks = 0,
|
||||
FatherId = fatherId,
|
||||
MotherId = motherId,
|
||||
ExpectedGoHomeDate = (string?)null,
|
||||
Notes = "Test litter notes",
|
||||
PairingCode = "PC01",
|
||||
ExternalRef = "ext-litter-1",
|
||||
LitterLetter = "A"
|
||||
}
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = fatherId,
|
||||
Name = "Papa",
|
||||
Gender = "male",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-papa",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "papa",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = motherId,
|
||||
Name = "Mama",
|
||||
Gender = "female",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-mama",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "mama",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true
|
||||
}
|
||||
},
|
||||
GerbilPhotos = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = photoId,
|
||||
GerbilId = fatherId,
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_loads_data_and_resolves_all_relations()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
var result = await service.RunAsync();
|
||||
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Verify counts
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
Assert.Equal(1, await db.Litters.CountAsync());
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
|
||||
|
||||
// Verify relations
|
||||
var litter = await db.Litters.SingleAsync();
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
|
||||
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
|
||||
var photo = await db.GerbilPhotos.SingleAsync();
|
||||
|
||||
Assert.Equal(father.Id, litter.FatherId);
|
||||
Assert.Equal(mother.Id, litter.MotherId);
|
||||
Assert.Equal(father.Id, photo.GerbilId);
|
||||
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
|
||||
public class LitterMortalityTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static int? GetInt(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32() : null;
|
||||
|
||||
private async Task<JsonElement> CreateAndGet(object body)
|
||||
{
|
||||
var post = await _client.PostAsJsonAsync("/litters", body);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_round_trips()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Wurf Mortalitätstest",
|
||||
date = "2026-05-01",
|
||||
totalBorn = 6,
|
||||
deathsWithin8Weeks = 2,
|
||||
});
|
||||
Assert.Equal(6, GetInt(el, "totalBorn"));
|
||||
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_defaults_null()
|
||||
{
|
||||
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
|
||||
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Ungültig",
|
||||
date = "2026-05-03",
|
||||
totalBorn = 4,
|
||||
deathsWithin8Weeks = 5,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("TotalBorn", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negative_DeathsWithin8Weeks_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Negativ",
|
||||
date = "2026-05-04",
|
||||
deathsWithin8Weeks = -1,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("negativ", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Kein TotalBorn",
|
||||
date = "2026-05-05",
|
||||
deathsWithin8Weeks = 3,
|
||||
});
|
||||
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||
/// fence strip), 503-not-configured path, upstream-error path.
|
||||
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("origin", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("thematischen Kategorien", prompt);
|
||||
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", prompt);
|
||||
Assert.Contains("weibliche", prompt);
|
||||
Assert.Contains("norn,mythg", prompt);
|
||||
// codes are mapped to German descriptions
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Griechische Mythologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||
{
|
||||
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||
Assert.Equal(expectedDescription, entry.Description);
|
||||
Assert.True(entry.Thematic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||
Assert.Contains(expectedDescription, prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
Assert.Contains("Disney-Charaktere", prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||
}
|
||||
|
||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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") ?? "");
|
||||
@@ -91,11 +91,11 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("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"));
|
||||
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
|
||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
private readonly HttpClient _client;
|
||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||
new { name, gender = "female", status, originBreeder });
|
||||
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
public async Task Combined_status_and_name_filter()
|
||||
{
|
||||
await Create("KombiVerkauf", status: "ForSale");
|
||||
await Create("KombiAktiv", status: "Active");
|
||||
await Create("KombiAktiv", status: "Breeding");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
|
||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||
/// In production this volume is shared with the publicsite-nginx container.
|
||||
/// </summary>
|
||||
public sealed class PublicSiteOptions
|
||||
{
|
||||
public const string SectionName = "PublicSite";
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -29,19 +30,22 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
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);
|
||||
|
||||
@@ -72,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -79,18 +84,20 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
bool? IsResident,
|
||||
bool? IsCastrated);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||
{
|
||||
if (!opts.Value.IsConfigured)
|
||||
return Results.Problem(
|
||||
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||
statusCode: 503,
|
||||
title: "PublicSite nicht konfiguriert");
|
||||
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||
return Results.Ok(new { filesPublished = files.Count });
|
||||
});
|
||||
|
||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||
/// </summary>
|
||||
internal static async Task PublishToDirectoryAsync(
|
||||
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||
{
|
||||
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||
var liveDir = Path.Combine(rootPath, "live");
|
||||
var oldDir = Path.Combine(rootPath, "_old");
|
||||
|
||||
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
foreach (var (relativePath, content) in files)
|
||||
{
|
||||
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Atomic swap: _staging_new → live
|
||||
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||
Directory.Move(stagingDir, liveDir);
|
||||
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||
catch { /* non-fatal — old dir gone on next publish */ }
|
||||
}
|
||||
|
||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||
private static string PreviewContentType(string path) =>
|
||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -104,11 +105,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
db.SaleContracts.Add(entity);
|
||||
|
||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
g.ReceiverContactId = contact.Id;
|
||||
g.GoHomeDate = input.HandoverDate;
|
||||
g.Status = GerbilStatus.GivenAway;
|
||||
GerbilStatusService.Apply(g, today);
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Gridify;
|
||||
using Gridify.EntityFramework;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
{
|
||||
var q = query.ToQuery();
|
||||
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
|
||||
var ids = paging.Data.Select(g => g.Id).ToList();
|
||||
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
|
||||
await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
|
||||
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
|
||||
var items = paging.Data
|
||||
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
|
||||
.ToList();
|
||||
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
|
||||
});
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
var photoFileName = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => p.GerbilId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => p.FileName)
|
||||
.FirstOrDefaultAsync();
|
||||
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
|
||||
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
|
||||
});
|
||||
|
||||
// POST /gerbils
|
||||
@@ -86,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
@@ -97,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;
|
||||
@@ -105,12 +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) => new(
|
||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||
g.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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||
Apply(l, input);
|
||||
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
l.Name = input.Name;
|
||||
l.Date = input.Date;
|
||||
Apply(l, input);
|
||||
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
private static void Apply(Litter l, LitterInput i)
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
}
|
||||
|
||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||
{
|
||||
if (deaths is null) return null;
|
||||
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
|
||||
if (totalBorn is not null && deaths > totalBorn)
|
||||
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
|
||||
@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
|
||||
|
||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||
{
|
||||
[GerbilStatus.Active] = "Aktiv",
|
||||
[GerbilStatus.Breeding] = "Zucht",
|
||||
[GerbilStatus.Deceased] = "Verstorben",
|
||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||
[GerbilStatus.ForSale] = "Abzugeben",
|
||||
[GerbilStatus.Pet] = "Liebhaber",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
@@ -54,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);
|
||||
}
|
||||
|
||||
@@ -266,14 +278,14 @@ namespace GerbilManagerWebAPI.Import
|
||||
Name = da.Name.Trim(),
|
||||
DateOfBirth = animalDob,
|
||||
Gender = ParseGender(da.Gender),
|
||||
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
|
||||
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
LitterId = litterId,
|
||||
ReceiverContactId = receiverId,
|
||||
GoHomeDate = goHomeDate,
|
||||
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,
|
||||
@@ -312,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.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
_db.Gerbils.Add(importedGerbil);
|
||||
}
|
||||
|
||||
// photos
|
||||
@@ -568,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
|
||||
@@ -612,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);
|
||||
|
||||
255
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
255
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
public sealed class IngestResolvedService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
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()
|
||||
{
|
||||
if (!File.Exists(_resolvedJsonPath))
|
||||
{
|
||||
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
|
||||
}
|
||||
|
||||
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return "Error: Failed to deserialize resolved_import.json.";
|
||||
}
|
||||
|
||||
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
|
||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||
{
|
||||
// Break circular references first
|
||||
foreach (var l in _db.Litters)
|
||||
{
|
||||
l.FatherId = null;
|
||||
l.MotherId = null;
|
||||
}
|
||||
foreach (var g in _db.Gerbils)
|
||||
{
|
||||
g.LitterId = null;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||
_db.Litters.RemoveRange(_db.Litters);
|
||||
_db.Contacts.RemoveRange(_db.Contacts);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Break circular references first using ExecuteUpdateAsync
|
||||
await _db.Litters.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(l => l.FatherId, (Guid?)null)
|
||||
.SetProperty(l => l.MotherId, (Guid?)null));
|
||||
|
||||
await _db.Gerbils.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(g => g.LitterId, (Guid?)null));
|
||||
|
||||
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||
await _db.Gerbils.ExecuteDeleteAsync();
|
||||
await _db.Litters.ExecuteDeleteAsync();
|
||||
await _db.Contacts.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 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 (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++;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
|
||||
var littersToInsert = new List<Litter>();
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
littersToInsert.Add(new Litter
|
||||
{
|
||||
Id = l.Id,
|
||||
Name = l.Name,
|
||||
Date = l.Date,
|
||||
TotalBorn = l.TotalBorn,
|
||||
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
|
||||
FatherId = null, // Set in Pass 2
|
||||
MotherId = null, // Set in Pass 2
|
||||
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
|
||||
Notes = l.Notes,
|
||||
PairingCode = l.PairingCode,
|
||||
ExternalRef = l.ExternalRef,
|
||||
LitterLetter = l.LitterLetter
|
||||
});
|
||||
}
|
||||
_db.Litters.AddRange(littersToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
|
||||
var gerbilsToInsert = new List<Gerbil>();
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
gerbilsToInsert.Add(new Gerbil
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Gender = g.Gender,
|
||||
Status = g.Status,
|
||||
LitterId = null, // Set in Pass 2
|
||||
OriginContactId = g.OriginContactId,
|
||||
ReceiverContactId = g.ReceiverContactId,
|
||||
EnclosureId = g.EnclosureId,
|
||||
ColorVarietyId = g.ColorVarietyId,
|
||||
DateOfBirth = g.DateOfBirth,
|
||||
DateOfDeath = g.DateOfDeath,
|
||||
CauseOfDeath = g.CauseOfDeath,
|
||||
GoHomeDate = g.GoHomeDate,
|
||||
Genotype = g.Genotype,
|
||||
Notes = g.Notes,
|
||||
ImportSource = g.ImportSource,
|
||||
ExternalRef = g.ExternalRef,
|
||||
RawImportData = g.RawImportData,
|
||||
OriginBreeder = g.OriginBreeder,
|
||||
NameSearch = g.NameSearch,
|
||||
CharacterTraits = g.CharacterTraits,
|
||||
CharacterNote = g.CharacterNote,
|
||||
IsDeaf = g.IsDeaf,
|
||||
IsResident = g.IsResident
|
||||
});
|
||||
}
|
||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Pass 2: Set the actual foreign key relationships
|
||||
// Update Litters with their parent animal IDs
|
||||
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
|
||||
{
|
||||
dbLitter.FatherId = l.FatherId;
|
||||
dbLitter.MotherId = l.MotherId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Gerbils with their actual LitterId
|
||||
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
||||
{
|
||||
dbGerbil.LitterId = g.LitterId;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Insert all Photos & copy physical files
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
foreach (var p in data.GerbilPhotos)
|
||||
{
|
||||
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 ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
||||
}
|
||||
}
|
||||
|
||||
public class ResolvedImportData
|
||||
{
|
||||
public List<Contact> Contacts { get; set; } = new();
|
||||
public List<Litter> Litters { get; set; } = new();
|
||||
public List<Gerbil> Gerbils { 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; }
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class StatusModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Rename string value 'Active' → 'Breeding'.
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
|
||||
|
||||
// 2) DateOfDeath set → Deceased (always takes precedence).
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
|
||||
|
||||
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
|
||||
WHERE ""Status"" != 'Deceased'
|
||||
AND (""ReceiverContactId"" IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||
|
||||
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
|
||||
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
|
||||
AND ""DateOfBirth"" IS NOT NULL
|
||||
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
|
||||
}
|
||||
}
|
||||
}
|
||||
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.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 AddLitterMortality : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DeathsWithin8Weeks",
|
||||
table: "Litters",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeathsWithin8Weeks",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
@@ -924,6 +936,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int?>("DeathsWithin8Weeks")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
|
||||
public Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public Gender Gender { get; set; }
|
||||
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
|
||||
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
|
||||
|
||||
// Birth litter (the litter this gerbil was born in).
|
||||
public Guid? LitterId { 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>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
|
||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
|
||||
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
|
||||
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
|
||||
public enum GerbilStatus
|
||||
{
|
||||
Active = 0,
|
||||
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
|
||||
Breeding = 0,
|
||||
Deceased = 1,
|
||||
GivenAway = 2,
|
||||
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
||||
ForSale = 3
|
||||
ForSale = 3,
|
||||
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
|
||||
Pet = 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Total born count (was "Strength").</summary>
|
||||
public int? TotalBorn { get; set; }
|
||||
|
||||
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
|
||||
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
|
||||
public int? DeathsWithin8Weeks { get; set; }
|
||||
|
||||
public Guid? FatherId { get; set; }
|
||||
public Gerbil? Father { get; set; }
|
||||
public Guid? MotherId { get; set; }
|
||||
|
||||
@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Known usage codes → (German description, isThematic).
|
||||
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
|
||||
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["norn"] = ("Nordische/Altnordische Etymologie", false),
|
||||
["japa"] = ("Japanische Etymologie", false),
|
||||
["mythg"] = ("Griechische Mythologie", false),
|
||||
["ger"] = ("Germanische/Deutsche Etymologie", false),
|
||||
["arb"] = ("Arabische Etymologie", false),
|
||||
["disney"] = ("Disney-Charaktere", true),
|
||||
["pokemon"] = ("Pokémon-Namen", true),
|
||||
["encities"] = ("Namen englischer Städte", true),
|
||||
["hrcities"] = ("Namen kroatischer Städte", true),
|
||||
["usstates"] = ("Namen von US-Bundesstaaten", true),
|
||||
};
|
||||
|
||||
public async Task<NameSuggestionResult> SuggestAsync(
|
||||
string? letter, string? gender, string? usages, int count,
|
||||
CancellationToken ct = default)
|
||||
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
|
||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
|
||||
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
|
||||
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
|
||||
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
|
||||
"Erfinde KEINE Etymologie für thematische Kategorien.";
|
||||
|
||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||
{
|
||||
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
|
||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||
|
||||
bool hasThematic = false;
|
||||
if (!string.IsNullOrWhiteSpace(usages))
|
||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
{
|
||||
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var etymDescriptions = new List<string>();
|
||||
var thematicDescriptions = new List<string>();
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (UsageMap.TryGetValue(code, out var entry))
|
||||
{
|
||||
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
|
||||
else etymDescriptions.Add(entry.Description);
|
||||
}
|
||||
else
|
||||
{
|
||||
etymDescriptions.Add(code);
|
||||
}
|
||||
}
|
||||
if (etymDescriptions.Count > 0)
|
||||
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
|
||||
if (thematicDescriptions.Count > 0)
|
||||
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
|
||||
hasThematic = thematicDescriptions.Count > 0;
|
||||
}
|
||||
|
||||
sb.Append(". ");
|
||||
if (hasThematic)
|
||||
{
|
||||
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
|
||||
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
|
||||
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
}
|
||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||
return sb.ToString();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Scalar.AspNetCore;
|
||||
@@ -48,6 +50,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
|
||||
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
|
||||
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||
@@ -81,7 +86,23 @@ app.MapScalarApiReference();
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
db.Database.Migrate();
|
||||
|
||||
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
|
||||
// since the last write. No-op if all statuses are already current.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var candidates = await db.Gerbils
|
||||
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
|
||||
&& g.DateOfDeath == null && g.ReceiverContactId == null
|
||||
&& g.DateOfBirth != null
|
||||
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
|
||||
.ToListAsync();
|
||||
if (candidates.Count > 0)
|
||||
{
|
||||
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
app.UseCors(LanCorsPolicy);
|
||||
|
||||
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
|
||||
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
|
||||
/// derived statuses (Deceased, GivenAway) are always consistent.
|
||||
///
|
||||
/// Precedence (highest wins):
|
||||
/// 1) DateOfDeath set → Deceased (explicit, always)
|
||||
/// 2) Abgabe (ReceiverContactId set) → GivenAway
|
||||
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
|
||||
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
|
||||
///
|
||||
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
|
||||
/// a) Apply() on every write (catches the animal at write time)
|
||||
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
|
||||
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
|
||||
/// </summary>
|
||||
public static class GerbilStatusService
|
||||
{
|
||||
public const int MaxAgeYears = 7;
|
||||
|
||||
/// <summary>Derives and sets g.Status using the gerbil's current field values.
|
||||
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
||||
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
|
||||
public static void Apply(Gerbil g, DateOnly today)
|
||||
{
|
||||
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
|
||||
isAbgegeben: g.ReceiverContactId is not null, today);
|
||||
}
|
||||
|
||||
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
|
||||
public static GerbilStatus Derive(
|
||||
GerbilStatus requested,
|
||||
DateOnly? dateOfBirth,
|
||||
DateOnly? dateOfDeath,
|
||||
bool isAbgegeben,
|
||||
DateOnly today)
|
||||
{
|
||||
if (dateOfDeath is not null) return GerbilStatus.Deceased;
|
||||
if (isAbgegeben) return GerbilStatus.GivenAway;
|
||||
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
|
||||
return GerbilStatus.Deceased;
|
||||
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
|
||||
? requested : GerbilStatus.Breeding;
|
||||
}
|
||||
|
||||
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
|
||||
today >= dob.AddYears(years);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ _Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in
|
||||
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|
||||
|---|---|---|
|
||||
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
|
||||
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
|
||||
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
|
||||
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,20 +7,25 @@ POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
|
||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
||||
REGISTRY=truenas:13000/gulum
|
||||
# Container Registry (git.rismer.de/gulum)
|
||||
REGISTRY=git.rismer.de/gulum
|
||||
TAG=latest
|
||||
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
|
||||
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||
KEYS_PATH=/mnt/SSD/gerbil/keys
|
||||
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
|
||||
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
|
||||
PUBLICSITE_PORT=8081
|
||||
|
||||
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
|
||||
# --- .NET API (GerbilManagerWebAPI) ---
|
||||
api:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
@@ -48,9 +48,12 @@ services:
|
||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||
AI__ApiKey: "${AI__ApiKey:-}"
|
||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
|
||||
PublicSite__RootPath: /data/publicsite
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
- keys:/data/keys
|
||||
- publicsite:/data/publicsite
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -63,7 +66,7 @@ services:
|
||||
|
||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||
frontend:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
@@ -74,6 +77,21 @@ services:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
# --- nginx Public Site (WEB-2) ---
|
||||
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
|
||||
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
|
||||
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
|
||||
publicsite:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PUBLICSITE_PORT:-8081}:80"
|
||||
volumes:
|
||||
- publicsite:/usr/share/nginx/html:ro
|
||||
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||
backup:
|
||||
image: postgres:17-alpine
|
||||
@@ -95,19 +113,19 @@ services:
|
||||
|
||||
volumes:
|
||||
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
||||
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
|
||||
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
||||
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
|
||||
photos:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
|
||||
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||
keys:
|
||||
@@ -115,10 +133,17 @@ volumes:
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
|
||||
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
|
||||
publicsite:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
|
||||
|
||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
# GerbilManager — publicsite nginx (WEB-2)
|
||||
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html/live;
|
||||
index index.html;
|
||||
charset utf-8;
|
||||
|
||||
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
|
||||
location / {
|
||||
try_files $uri $uri/index.html =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
}
|
||||
|
||||
# CSS/Bilder: kurze TTL (1 Tag)
|
||||
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||
try_files $uri =404;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
# Kein Zugriff auf Staging-Verzeichnisse
|
||||
location ~ ^/_(staging_new|old)/ {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||
#
|
||||
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||
server {
|
||||
listen 80;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name <DOMAIN>;
|
||||
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:8081;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
327
docs/ops.md
327
docs/ops.md
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||
@@ -9,13 +9,12 @@
|
||||
|
||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||
2. [Voraussetzungen](#2-voraussetzungen)
|
||||
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
|
||||
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
|
||||
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
||||
8. [Fehlerbehebung](#8-fehlerbehebung)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@
|
||||
|
||||
```
|
||||
Browser / Handy
|
||||
| HTTP :80
|
||||
| HTTP :80 (oder PORT aus .env, z.B. 8080)
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||
@@ -40,9 +39,10 @@ Browser / Handy
|
||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||
└──────────────────┘ │ pg_dump + tar + cron │
|
||||
│ └──────────────────────┘
|
||||
└─ pgdata-Volume (NAS-Dataset)
|
||||
photos-Volume (NAS-Dataset)
|
||||
backups-Volume (NAS-Dataset)
|
||||
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
```
|
||||
|
||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
|
||||
|
||||
| Was | Details |
|
||||
|-----|---------|
|
||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
||||
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
|
||||
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
|
||||
| Docker | bereits auf TrueNAS Goldeye vorhanden |
|
||||
| Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Erstinstallation auf TrueNAS
|
||||
## 3. Erstinstallation auf TrueNAS Goldeye
|
||||
|
||||
### 3.1 ZFS-Datasets anlegen
|
||||
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
|
||||
|
||||
In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen):
|
||||
|
||||
| Dataset-Name | Empfohlener Pfad | Verwendung |
|
||||
|---|---|---|
|
||||
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
|
||||
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
|
||||
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
|
||||
|
||||
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
|
||||
|
||||
### 3.2 Repository klonen
|
||||
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
|
||||
|
||||
```bash
|
||||
# SSH in TrueNAS oder lokale Shell
|
||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
||||
# Vier Ordner anlegen
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
|
||||
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
|
||||
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
|
||||
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
|
||||
```
|
||||
|
||||
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
|
||||
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
|
||||
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
|
||||
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
|
||||
|
||||
### 3.2 Registry-Login auf der NAS
|
||||
|
||||
```bash
|
||||
docker login git.rismer.de
|
||||
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
|
||||
```
|
||||
|
||||
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
|
||||
|
||||
### 3.3 Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
|
||||
cd /opt/gerbilmanager
|
||||
```
|
||||
|
||||
### 3.3 Konfiguration anlegen
|
||||
### 3.4 Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||
# Jetzt .env bearbeiten:
|
||||
nano deploy/truenas/.env
|
||||
```
|
||||
|
||||
Mindestens setzen:
|
||||
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
|
||||
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
|
||||
|
||||
### 3.4 Images bauen und App starten
|
||||
| Variable | Wert |
|
||||
|----------|------|
|
||||
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
|
||||
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
|
||||
| `AI__ApiKey` | Dein Gemini API-Key |
|
||||
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
|
||||
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
|
||||
|
||||
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
|
||||
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
|
||||
|
||||
### 3.5 Images ziehen und App starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
||||
|
||||
### 3.5 Prüfen
|
||||
> **TrueNAS Goldeye Custom App (Alternative):**
|
||||
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
|
||||
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
|
||||
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
|
||||
|
||||
### 3.6 Verifikation
|
||||
|
||||
```bash
|
||||
# Alle Container laufen?
|
||||
# Alle 4 Container laufen?
|
||||
docker compose -f deploy/truenas/compose.yaml ps
|
||||
|
||||
# API-Healthcheck
|
||||
curl http://localhost/api/health
|
||||
# API-Healthcheck (erwartet: {"status":"Healthy"})
|
||||
curl -s http://localhost/api/health
|
||||
|
||||
# Webapp im Browser
|
||||
http://<NAS-IP>/
|
||||
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# API-Doku (Scalar) im Browser
|
||||
http://<NAS-IP>/scalar
|
||||
|
||||
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
|
||||
```
|
||||
|
||||
---
|
||||
@@ -125,6 +164,7 @@ http://<NAS-IP>/
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
|
||||
docker compose -f deploy/truenas/compose.yaml down
|
||||
```
|
||||
|
||||
### Aktualisieren (nach `git push` auf main)
|
||||
### Aktualisieren (nach CI-Push auf main)
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
||||
|
||||
### Mit Gitea CI (wenn Actions aktiviert)
|
||||
|
||||
Push auf `main` triggert automatisch Build → Test → Image-Push.
|
||||
Danach auf der NAS:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup & Wiederherstellung
|
||||
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
|
||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
||||
|
||||
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
|
||||
Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
|
||||
|
||||
```
|
||||
/mnt/SSD/gerbil/backups/
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
|
||||
2026-06-06_03-00/
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
|
||||
photos_2026-06-06_03-00.tar.gz (Fotos)
|
||||
backup.log (Protokoll)
|
||||
```
|
||||
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
|
||||
### Backup-Log prüfen
|
||||
|
||||
```bash
|
||||
tail -50 /mnt/SSD/gerbil/backups/backup.log
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
```
|
||||
|
||||
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
|
||||
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
|
||||
|
||||
### Wiederherstellung — Runbook
|
||||
|
||||
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
|
||||
> **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
|
||||
|
||||
**Schritt 1:** App stoppen (optional, aber empfohlen)
|
||||
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||
```
|
||||
|
||||
**Schritt 2:** Restore ausführen
|
||||
```bash
|
||||
# Neuestes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh
|
||||
|
||||
# Bestimmtes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
||||
```bash
|
||||
# Neuestes Backup automatisch wählen und bestätigen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh latest -f
|
||||
|
||||
# Bestimmtes Backup (Datum aus Verzeichnisname):
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
1. Trennt alle offenen DB-Verbindungen
|
||||
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
|
||||
3. Entpackt das Foto-Archiv nach `/data/photos`
|
||||
|
||||
**Schritt 3:** API neu starten
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||
```
|
||||
|
||||
**Schritt 4:** Prüfen
|
||||
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
|
||||
```bash
|
||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
||||
# Erwarteter Wert: 73
|
||||
# Tier-Anzahl prüfen
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
|
||||
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
|
||||
|
||||
# Foto stichprobenartig prüfen
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
|
||||
```
|
||||
|
||||
### Restore-Nachweis (Round-Trip-Test)
|
||||
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
|
||||
|
||||
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
|
||||
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
|
||||
```
|
||||
73 ColorVarieties vorhanden
|
||||
→ DELETE 12 Zeilen → 61 verbleibend
|
||||
→ pg_restore eingespielt
|
||||
→ psql < dump.sql eingespielt
|
||||
→ 73 ColorVarieties bestätigt
|
||||
Exit-Code: 0
|
||||
```
|
||||
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
|
||||
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
|
||||
|
||||
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
|
||||
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
|
||||
|
||||
---
|
||||
|
||||
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
|
||||
|
||||
### Empfohlene Snapshot-Konfiguration
|
||||
|
||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
|
||||
| Dataset | Häufigkeit | Aufbewahrung |
|
||||
|---------|-----------|--------------|
|
||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
||||
| `gerbil/photos` | Täglich | 30 Tage |
|
||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
||||
| `gerbil/backups` | Täglich | 90 Tage |
|
||||
| Unterordner | Häufigkeit | Aufbewahrung |
|
||||
|-------------|-----------|--------------|
|
||||
| `.../photos` | Stündlich | 24 Stunden |
|
||||
| `.../photos` | Täglich | 30 Tage |
|
||||
| `.../pgdata` | Stündlich | 24 Stunden |
|
||||
| `.../pgdata` | Täglich | 30 Tage |
|
||||
| `.../backups` | Täglich | 90 Tage |
|
||||
|
||||
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
||||
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
||||
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
|
||||
### Snapshot manuell erstellen (z. B. vor Update)
|
||||
|
||||
```bash
|
||||
# TrueNAS CLI
|
||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
||||
# Pool-/Dataset-Name anpassen falls nötig
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||
|
||||
```bash
|
||||
# Snapshot auflisten
|
||||
zfs list -t snapshot SSD/gerbil/photos
|
||||
# Snapshots auflisten
|
||||
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
|
||||
# Datei aus Snapshot kopieren
|
||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
||||
# Einzelne Datei aus Snapshot kopieren
|
||||
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD via Gitea Actions
|
||||
|
||||
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
|
||||
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
|
||||
`git.rismer.de/gulum/gerbilmanager-frontend`.
|
||||
|
||||
### Aktivierung
|
||||
### Registry-Secrets in Gitea
|
||||
|
||||
1. **Gitea Actions aktivieren:**
|
||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
|
||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
||||
```bash
|
||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
||||
docker run -d --name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
-e GITEA_INSTANCE_URL=http://truenas:13000 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
|
||||
| Secret | Wert |
|
||||
|--------|------|
|
||||
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||
|
||||
3. **Registry-Secrets konfigurieren:**
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
||||
### Update nach CI-Push
|
||||
|
||||
### Workflow nach Aktivierung
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
||||
→ Auf NAS: docker compose pull + up -d
|
||||
```bash
|
||||
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
|
||||
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
|
||||
|
||||
| # | Frage | Auswirkung |
|
||||
|---|-------|-----------|
|
||||
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
|
||||
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
|
||||
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
|
||||
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
|
||||
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Fehlerbehebung
|
||||
## 8. Fehlerbehebung
|
||||
|
||||
### App startet nicht
|
||||
|
||||
```bash
|
||||
# Logs aller Container
|
||||
docker compose -f deploy/truenas/compose.yaml logs
|
||||
|
||||
# Logs eines bestimmten Containers
|
||||
docker compose -f deploy/truenas/compose.yaml logs api
|
||||
docker compose -f deploy/truenas/compose.yaml logs db
|
||||
```
|
||||
|
||||
### Port 80 belegt
|
||||
|
||||
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
|
||||
|
||||
```bash
|
||||
# In deploy/truenas/.env:
|
||||
PORT=8080
|
||||
# Dann neu starten:
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
### Postgres startet nicht (Permission denied auf pgdata)
|
||||
|
||||
```bash
|
||||
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
docker compose -f deploy/truenas/compose.yaml restart db
|
||||
```
|
||||
|
||||
### Registry-Pull schlägt fehl
|
||||
|
||||
```bash
|
||||
# Neu einloggen:
|
||||
docker login git.rismer.de
|
||||
# Dann pull wiederholen:
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
```
|
||||
|
||||
### Datenbank nicht erreichbar
|
||||
|
||||
```bash
|
||||
# DB-Container läuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps db
|
||||
|
||||
# Verbindung testen
|
||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
psql -U postgres -d gerbilmanager -c "\dt"
|
||||
```
|
||||
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
### Backup-Fehler
|
||||
|
||||
```bash
|
||||
# Backup-Log prüfen
|
||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
||||
|
||||
# Backup manuell starten (mit Fehlerausgabe)
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/backup.sh
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Fotos werden nicht angezeigt
|
||||
|
||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
||||
```
|
||||
|
||||
### Container-Status zurücksetzen (Neustart)
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml restart api
|
||||
```
|
||||
|
||||
### Kompletter Neustart (Daten bleiben erhalten)
|
||||
|
||||
```bash
|
||||
|
||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
|
||||
|
||||
> **Zielgruppe:** Julian.
|
||||
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
|
||||
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Internet
|
||||
| HTTPS/HTTP
|
||||
v
|
||||
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
|
||||
| proxy_pass http://127.0.0.1:8081
|
||||
v
|
||||
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
|
||||
| (Shared Volume, read-only)
|
||||
| [POST /api/publish im Manager schreibt in dasselbe Volume]
|
||||
v
|
||||
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
|
||||
| (Shared Volume, read-write)
|
||||
v
|
||||
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
|
||||
```
|
||||
|
||||
**SICHERHEIT — harte Bedingung:**
|
||||
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
|
||||
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
|
||||
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
|
||||
|
||||
---
|
||||
|
||||
## Erstinstallation
|
||||
|
||||
### 1. Verzeichnis anlegen
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
```
|
||||
|
||||
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
|
||||
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
|
||||
|
||||
### 2. .env erganzen
|
||||
|
||||
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
|
||||
|
||||
```env
|
||||
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
PUBLICSITE_PORT=8081
|
||||
```
|
||||
|
||||
### 3. Compose-Stack neu starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Der neue `publicsite`-Container startet und serviert Port 8081.
|
||||
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
|
||||
|
||||
### 4. Julians externen nginx konfigurieren
|
||||
|
||||
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
|
||||
(als eigenen `server`-Block oder per `include`):
|
||||
|
||||
```bash
|
||||
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
|
||||
nano /etc/nginx/conf.d/gerbilmanager-public.conf
|
||||
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seite veroeffentlichen (Publish-Ablauf)
|
||||
|
||||
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
|
||||
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
|
||||
|
||||
**Was passiert intern:**
|
||||
```
|
||||
POST /api/publish
|
||||
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
|
||||
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
|
||||
→ Schreibt Dateien nach /data/publicsite/_staging_new/
|
||||
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
|
||||
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
|
||||
→ Kein Container-Restart, kein Image-Rebuild, kein CI
|
||||
Response: { "filesPublished": N }
|
||||
```
|
||||
|
||||
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
|
||||
|
||||
---
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
# publicsite-Container laeuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps publicsite
|
||||
|
||||
# Seite lokal abrufbar?
|
||||
curl -s http://localhost:8081/ | head -5
|
||||
|
||||
# live/-Verzeichnis gefuellt?
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
|
||||
|
||||
# Oeffentlich erreichbar (nach DNS-Propagation)?
|
||||
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitstrennung (Pflichtcheck)
|
||||
|
||||
| Was | Port | Internet-exponiert? |
|
||||
|-----|------|---------------------|
|
||||
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
|
||||
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
|
||||
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
|
||||
|
||||
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
|
||||
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
|
||||
Sie sind ausschliesslich im Heimnetz erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## Hostname noch ausstehend
|
||||
|
||||
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||
@@ -1,5 +1,5 @@
|
||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
@@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
|
||||
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
|
||||
|
||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||
await expect(maxRow).toBeVisible()
|
||||
|
||||
70
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
70
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
|
||||
const genotypeInput = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
// Vor der Auswahl ist das Gencode-Feld leer.
|
||||
await expect(genotypeInput).toHaveValue('')
|
||||
|
||||
// 'Agouti' wählen → Gencode wird automatisch befüllt.
|
||||
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')
|
||||
})
|
||||
|
||||
test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
|
||||
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(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 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 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(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 genotypeInput = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
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(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||
})
|
||||
@@ -1,85 +1,54 @@
|
||||
/**
|
||||
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
|
||||
*
|
||||
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
|
||||
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
|
||||
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.describe('Quick-Chips-Filter – Rennmäuse-Liste', () => {
|
||||
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Toggle-Button sichtbar.
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
const quickrow = page.locator('.rmf-quickrow')
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Öffnen → Status-Feld wird sichtbar.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
// Default: Zucht-Chip ist aktiv.
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||
|
||||
// Schließen → wieder verborgen.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
|
||||
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
|
||||
|
||||
// „Alle löschen" entfernt sämtliche Chips.
|
||||
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
|
||||
await expect(page.locator('.rmf-chip')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
|
||||
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||
await toggle.click()
|
||||
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||
await expect(toggle).toHaveText('Filter (1)')
|
||||
|
||||
// Zurücksetzen → Badge weg.
|
||||
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
})
|
||||
|
||||
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'desktop') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeHidden()
|
||||
|
||||
// Status-Beschriftung direkt sichtbar.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
const breedingChip = page.locator('.rmf-chip', {
|
||||
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
|
||||
})
|
||||
await expect(breedingChip).toBeVisible()
|
||||
await breedingChip.click()
|
||||
await expect(breedingChip).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
|
||||
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
|
||||
*/
|
||||
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
|
||||
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
|
||||
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
|
||||
const sheet = page.getByRole('dialog', { name: categoryLabel })
|
||||
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
|
||||
await sheet.getByRole('button', { name: de.filterBar.done }).click()
|
||||
}
|
||||
|
||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||
export const uniqueName = (prefix: string) =>
|
||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
|
||||
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.litters
|
||||
|
||||
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
|
||||
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
|
||||
await expect(kruemelCard).toBeVisible()
|
||||
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
|
||||
|
||||
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
|
||||
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||
await expect(pippaCard).toBeVisible()
|
||||
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
|
||||
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
|
||||
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
|
||||
|
||||
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
|
||||
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||
await expect(bennyCard).toBeVisible()
|
||||
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
|
||||
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
|
||||
})
|
||||
|
||||
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
|
||||
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
|
||||
|
||||
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
|
||||
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
|
||||
await expect(bennyCard.locator('svg')).toBeVisible()
|
||||
})
|
||||
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
|
||||
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.litters
|
||||
|
||||
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
// Frühverluste-Zeile im def-list: Label + Wert '1'
|
||||
const mortalityRow = page.locator('.def-row', {
|
||||
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||
})
|
||||
await expect(mortalityRow).toBeVisible()
|
||||
await expect(mortalityRow.locator('dd')).toHaveText('1')
|
||||
})
|
||||
|
||||
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
const name = uniqueName('WurfM')
|
||||
await page.goto('/wuerfe/neu')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
|
||||
|
||||
// Frühverluste eingeben
|
||||
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
|
||||
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
// Frühverluste-Zeile im Detail
|
||||
const mortalityRow = page.locator('.def-row', {
|
||||
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||
})
|
||||
await expect(mortalityRow).toBeVisible()
|
||||
await expect(mortalityRow.locator('dd')).toHaveText('2')
|
||||
})
|
||||
|
||||
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/neu')
|
||||
|
||||
await page.getByLabel(t.fields.totalBorn).fill('3')
|
||||
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
|
||||
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).toBeVisible()
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ function gerbil(
|
||||
id,
|
||||
name,
|
||||
gender,
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
@@ -108,6 +108,7 @@ function gerbil(
|
||||
notes: null,
|
||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||
isResident: true,
|
||||
profilePhotoUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +121,7 @@ export function seedDb(): MockDb {
|
||||
originContactId: 'con-meier',
|
||||
originBreeder: 'Clan-Kleine-Chaoten',
|
||||
},
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
||||
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||
@@ -135,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).
|
||||
{
|
||||
@@ -151,12 +154,27 @@ export function seedDb(): MockDb {
|
||||
enclosureId: 'enc-leer',
|
||||
notes: 'neugieriger Entdecker',
|
||||
},
|
||||
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
|
||||
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
|
||||
{
|
||||
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
|
||||
status: 'GivenAway' as const,
|
||||
goHomeDate: '2025-05-01',
|
||||
receiverContactId: 'con-huber',
|
||||
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
},
|
||||
{
|
||||
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
|
||||
status: 'Deceased' as const,
|
||||
dateOfDeath: '2025-04-15',
|
||||
causeOfDeath: 'Altersschwäche',
|
||||
},
|
||||
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
||||
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
||||
]
|
||||
|
||||
const litters: Litter[] = [
|
||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' },
|
||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
|
||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||
@@ -173,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 = [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures'
|
||||
|
||||
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
||||
// DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains.
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
|
||||
@@ -137,3 +137,37 @@ test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', a
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
|
||||
})
|
||||
|
||||
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
|
||||
})
|
||||
|
||||
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
|
||||
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
|
||||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
|
||||
|
||||
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
|
||||
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
|
||||
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
|
||||
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
|
||||
})
|
||||
|
||||
94
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
94
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
|
||||
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
const statusField = page.locator('label.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
// Zucht (Breeding) ist vorausgewählt
|
||||
await expect(statusField.locator('select')).toHaveValue('Breeding')
|
||||
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
|
||||
const opts = statusField.locator('select option')
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Willi ist Deceased
|
||||
await page.goto('/rennmaeuse/willi/bearbeiten')
|
||||
// Status-Select ist NICHT vorhanden (abgeleitet)
|
||||
const statusField = page.locator('.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
await expect(statusField).toBeVisible()
|
||||
await expect(statusField.locator('select')).toHaveCount(0)
|
||||
await expect(statusField).toContainText(t.statusLabels.Deceased)
|
||||
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
|
||||
})
|
||||
|
||||
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
// DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip.
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
const name = uniqueName('Tier')
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
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}")`),
|
||||
})
|
||||
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
|
||||
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()
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
const tabs = de.pages.tierTabs
|
||||
@@ -23,13 +23,9 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||
await page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||
.locator('select')
|
||||
.selectOption({ label: 'Clan-Kleine-Chaoten' })
|
||||
// DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet
|
||||
// auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern.
|
||||
await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten')
|
||||
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||
@@ -56,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
|
||||
@@ -178,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()
|
||||
@@ -193,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()
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
|
||||
<!-- DESIGN (Rennmaus Filter): Hanken Grotesk als App-Schrift. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Rennmaus-Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
|
||||
expect(codes).toContain('mythg')
|
||||
expect(codes).toContain('ger')
|
||||
expect(codes).toContain('arb')
|
||||
expect(codes).toHaveLength(5)
|
||||
expect(codes).toContain('disney')
|
||||
expect(codes).toContain('pokemon')
|
||||
expect(codes).toContain('encities')
|
||||
expect(codes).toContain('hrcities')
|
||||
expect(codes).toContain('usstates')
|
||||
expect(codes).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('every usage has a non-empty label', () => {
|
||||
|
||||
@@ -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}. */
|
||||
|
||||
@@ -61,6 +61,23 @@ export function andFilter(...conditions: Array<string | null | undefined | false
|
||||
return conditions.filter((c): c is string => Boolean(c)).join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OR-group of conditions for one field, e.g. `(status=Breeding|status=ForSale)`.
|
||||
* Used for multi-select filters: several allowed values within one category are OR'd,
|
||||
* while categories are AND'd together by {@link andFilter}.
|
||||
* Returns '' for an empty value list (so andFilter drops it); a single value needs no parens.
|
||||
*/
|
||||
export function orGroup(
|
||||
field: string,
|
||||
values: ReadonlyArray<string | number | boolean>,
|
||||
op: GridifyOp = '==',
|
||||
): string {
|
||||
const parts = values.map((value) => condition({ field, op, value }))
|
||||
if (parts.length === 0) return ''
|
||||
if (parts.length === 1) return parts[0]
|
||||
return `(${parts.join('|')})`
|
||||
}
|
||||
|
||||
/** Assemble the URL query string ("?...") from a GridifyQuery. */
|
||||
export function toQueryString(q: GridifyQuery): string {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
|
||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||
{ code: 'ger', label: 'Deutsch' },
|
||||
{ code: 'arb', label: 'Arabisch' },
|
||||
{ code: 'disney', label: 'Disney' },
|
||||
{ code: 'pokemon', label: 'Pokémon' },
|
||||
{ code: 'encities', label: 'Englische Städte' },
|
||||
{ code: 'hrcities', label: 'Kroatische Städte' },
|
||||
{ code: 'usstates', label: 'US-Bundesstaaten' },
|
||||
] as const
|
||||
|
||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
export type Gender = 'unknown' | 'male' | 'female'
|
||||
export const GENDERS: Gender[] = ['unknown', 'male', 'female']
|
||||
|
||||
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */
|
||||
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale'
|
||||
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway']
|
||||
/** Gerbil.Status — C# enum { Breeding, Pet, Deceased, GivenAway, ForSale }. STATUS-MODEL: Active→Breeding (gated on Pam feature/status-model). */
|
||||
export type GerbilStatus = 'Breeding' | 'Pet' | 'Deceased' | 'GivenAway' | 'ForSale'
|
||||
export const GERBIL_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale', 'Deceased', 'GivenAway']
|
||||
/** Statuses the user can pick freely in the form; Deceased+GivenAway are derived by the backend. */
|
||||
export const SELECTABLE_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale']
|
||||
|
||||
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
|
||||
export type DateOnlyString = string
|
||||
@@ -56,6 +58,10 @@ export interface Gerbil {
|
||||
isResident?: boolean
|
||||
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||
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. */
|
||||
@@ -79,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). */
|
||||
@@ -104,6 +112,8 @@ export interface Contact {
|
||||
phone: string | null
|
||||
address: string | null
|
||||
notes: string | null
|
||||
isBreeder: boolean
|
||||
isReceiver: boolean
|
||||
}
|
||||
|
||||
export interface Litter {
|
||||
@@ -122,6 +132,8 @@ export interface Litter {
|
||||
notes?: string | null
|
||||
fatherId: string | null
|
||||
motherId: string | null
|
||||
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
||||
deathsWithin8Weeks?: number | null
|
||||
}
|
||||
|
||||
/** Payload for POST /litters. */
|
||||
@@ -133,6 +145,7 @@ export interface CreateLitter {
|
||||
notes?: string | null
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
deathsWithin8Weeks?: number | null
|
||||
}
|
||||
|
||||
export type UpdateLitter = Partial<CreateLitter>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import GerbilIcon from './GerbilIcon'
|
||||
import './appShell.css'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: string
|
||||
icon: ReactNode
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
// Primary items are always visible in the phone bottom bar; secondary items
|
||||
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
|
||||
const PRIMARY: NavItem[] = [
|
||||
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
||||
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: <GerbilIcon size="1.25em" /> },
|
||||
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
||||
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
||||
]
|
||||
@@ -29,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: '❓' },
|
||||
]
|
||||
|
||||
@@ -45,11 +48,6 @@ export default function AppShell() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="app-logo" aria-hidden="true">🐭</span>
|
||||
<h1 className="app-title">{de.app.title}</h1>
|
||||
</header>
|
||||
|
||||
<nav className="app-nav" aria-label={de.nav.mainNavigation}>
|
||||
<div className="nav-primary">
|
||||
{PRIMARY.map((item) => (
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
271
gerbil-manager-web/src/components/GerbilFilterBar.tsx
Normal file
271
gerbil-manager-web/src/components/GerbilFilterBar.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* DESIGN (Rennmaus Filter.html): mobile-first filter UX —
|
||||
* 1. a horizontal "Quick-Chips" pill row (one pill per category, with a count badge),
|
||||
* 2. tapping a pill opens a bottom-sheet of multi-select options (✓ checkboxes,
|
||||
* searchable for long lists like Farbschlag / Herkunft),
|
||||
* 3. active selections appear ABOVE the list as removable chips (so they scroll
|
||||
* away with the list), with "Alle löschen".
|
||||
*
|
||||
* The user landed on the Quick-Chips concept, dezent style, brown accent. Colors
|
||||
* come from the app's existing tokens. `GerbilFilterBar` is the pill row + sheet;
|
||||
* `ActiveFilterChips` renders the removable chips and is placed above the list.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { de } from '../strings/de'
|
||||
import './gerbilFilterBar.css'
|
||||
|
||||
export interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Optional swatch dot (used for status colors). */
|
||||
color?: string
|
||||
/** Optional leading symbol (used for gender ♂ ♀ ?). */
|
||||
symbol?: string
|
||||
}
|
||||
|
||||
export interface FilterCategory {
|
||||
/** Matches the Gridify field / the key into the selected map. */
|
||||
key: string
|
||||
label: string
|
||||
/** Renders a search box above the option list (for long lists). */
|
||||
searchable?: boolean
|
||||
options: FilterOption[]
|
||||
}
|
||||
|
||||
function countFor(selected: Record<string, string[]>, key: string): number {
|
||||
return (selected[key] ?? []).length
|
||||
}
|
||||
|
||||
interface GerbilFilterBarProps {
|
||||
categories: FilterCategory[]
|
||||
selected: Record<string, string[]>
|
||||
onToggle: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
/** The Quick-Chips pill row + the category bottom-sheet. */
|
||||
export function GerbilFilterBar({ categories, selected, onToggle }: GerbilFilterBarProps) {
|
||||
const t = de.filterBar
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null)
|
||||
const activeCat = categories.find((c) => c.key === activeKey) ?? null
|
||||
|
||||
return (
|
||||
<div className="rmf">
|
||||
<div className="rmf-quickrow" role="group" aria-label={t.title}>
|
||||
{categories.map((cat) => {
|
||||
const n = countFor(selected, cat.key)
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
type="button"
|
||||
className={'rmf-pill' + (n > 0 ? ' is-on' : '')}
|
||||
onClick={() => setActiveKey(cat.key)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{cat.label}
|
||||
{n > 0 && <span className="rmf-pill-count">{n}</span>}
|
||||
<span className="rmf-pill-chev" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeCat && (
|
||||
<FilterSheet
|
||||
cat={activeCat}
|
||||
selected={selected[activeCat.key] ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={() => setActiveKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActiveFilterChipsProps {
|
||||
categories: FilterCategory[]
|
||||
selected: Record<string, string[]>
|
||||
onRemove: (key: string, value: string) => void
|
||||
onClearAll: () => void
|
||||
/** Optional per-chip accent colour (e.g. status colours); falls back to the app accent. */
|
||||
chipColor?: (key: string, value: string) => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Removable chips for the active selections, placed above the list so they scroll
|
||||
* away as you browse. Wraps onto multiple lines — nothing is hidden.
|
||||
*/
|
||||
export function ActiveFilterChips({
|
||||
categories,
|
||||
selected,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
chipColor,
|
||||
}: ActiveFilterChipsProps) {
|
||||
const t = de.filterBar
|
||||
|
||||
const chips = categories.flatMap((cat) => {
|
||||
const labelByValue = new Map(cat.options.map((o) => [o.value, o.label]))
|
||||
return (selected[cat.key] ?? []).map((value) => ({
|
||||
key: cat.key,
|
||||
value,
|
||||
catLabel: cat.label,
|
||||
label: labelByValue.get(value) ?? value,
|
||||
}))
|
||||
})
|
||||
|
||||
if (chips.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rmf-chips">
|
||||
{chips.map((c) => {
|
||||
const accent = chipColor?.(c.key, c.value)
|
||||
return (
|
||||
<button
|
||||
key={c.key + c.value}
|
||||
type="button"
|
||||
className="rmf-chip"
|
||||
style={accent ? ({ '--chip': accent } as CSSProperties) : undefined}
|
||||
onClick={() => onRemove(c.key, c.value)}
|
||||
aria-label={`${c.catLabel}: ${c.label} — ${t.remove}`}
|
||||
>
|
||||
<span className="rmf-chip-cat">{c.catLabel}</span>
|
||||
<span className="rmf-chip-val">{c.label}</span>
|
||||
<span className="rmf-chip-x" aria-hidden="true">×</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{chips.length > 1 && (
|
||||
<button type="button" className="rmf-chip rmf-chip-clear" onClick={onClearAll}>
|
||||
{t.clearAll}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BottomSheetProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable bottom-sheet: dimmed scrim + slide-up panel with grip, title and a
|
||||
* "Fertig" button. Locks body scroll and closes on Escape / scrim tap. Used for
|
||||
* the category filters AND the sort selector (same drawer mechanic).
|
||||
*/
|
||||
export function BottomSheet({ title, onClose, children }: BottomSheetProps) {
|
||||
const t = de.filterBar
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Portal to <body> so the sheet escapes any sticky/stacking-context ancestor
|
||||
// (e.g. the sticky header) and renders above the bottom action bar.
|
||||
return createPortal(
|
||||
<div className="rmf-scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="rmf-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="rmf-grip" aria-hidden="true" />
|
||||
<div className="rmf-sheet-head">
|
||||
<span className="rmf-sheet-title">{title}</span>
|
||||
<button type="button" className="rmf-sheet-done" onClick={onClose}>
|
||||
{t.done}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rmf-sheet-body">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterSheetProps {
|
||||
cat: FilterCategory
|
||||
selected: string[]
|
||||
onToggle: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function FilterSheet({ cat, selected, onToggle, onClose }: FilterSheetProps) {
|
||||
return (
|
||||
<BottomSheet title={cat.label} onClose={onClose}>
|
||||
<OptionList cat={cat} selected={selected} onToggle={onToggle} />
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
interface OptionListProps {
|
||||
cat: FilterCategory
|
||||
selected: string[]
|
||||
onToggle: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
function OptionList({ cat, selected, onToggle }: OptionListProps) {
|
||||
const t = de.filterBar
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!cat.searchable) return cat.options
|
||||
const term = q.trim().toLowerCase()
|
||||
if (!term) return cat.options
|
||||
return cat.options.filter((o) => o.label.toLowerCase().includes(term))
|
||||
}, [cat, q])
|
||||
|
||||
return (
|
||||
<div className="rmf-picker">
|
||||
{cat.searchable && (
|
||||
<input
|
||||
className="rmf-search"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
aria-label={t.searchPlaceholder}
|
||||
/>
|
||||
)}
|
||||
<div className={'rmf-optlist' + (cat.searchable ? ' rmf-optlist--scroll' : '')}>
|
||||
{options.length === 0 && <div className="rmf-empty">{t.noMatch}</div>}
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o.value)
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={'rmf-opt' + (on ? ' is-on' : '')}
|
||||
onClick={() => onToggle(cat.key, o.value)}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="rmf-opt-box" aria-hidden="true">
|
||||
{on ? '✓' : ''}
|
||||
</span>
|
||||
{o.color && <span className="rmf-opt-dot" style={{ background: o.color }} />}
|
||||
{o.symbol && (
|
||||
<span className="rmf-opt-sym" aria-hidden="true">
|
||||
{o.symbol}
|
||||
</span>
|
||||
)}
|
||||
<span className="rmf-opt-label">{o.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
37
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
interface Props {
|
||||
size?: string | number
|
||||
style?: CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Reusable gerbil silhouette icon — upright sitting pose, long tail with tuft. fill=currentColor. */
|
||||
export default function GerbilIcon({ size = '1em', style, className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ display: 'inline-block', verticalAlign: '-0.125em', flexShrink: 0, ...style }}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Rennmaus"
|
||||
>
|
||||
{/* body — sitting upright */}
|
||||
<path d="M 34 23 C 38 20, 44 21, 46 25 C 47 27, 45 30, 41 31 C 38 32, 40 38, 40 42 C 40 48, 36 51, 31 52 C 25 52, 21 47, 21 40 C 21 31, 27 25, 34 23 Z" />
|
||||
{/* ears */}
|
||||
<path d="M 36 22 Q 33 13 39 11 Q 42 13 39 21 Z" />
|
||||
<path d="M 32 23 Q 30 15 35 13 Q 37 15 35 22 Z" opacity="0.6" />
|
||||
{/* front paw */}
|
||||
<path d="M 41 33 Q 46 33 46 36 Q 44 38 41 35 Z" />
|
||||
{/* hind paw */}
|
||||
<path d="M 21 49 C 19 53, 23 55, 30 55 C 33 55, 34 52, 30 50 Z" />
|
||||
{/* tail and tuft */}
|
||||
<path d="M 21 47 C 14 51, 9 46, 8 41 C 7 35, 11 27, 16 22 C 17 21, 18 22, 17 23 C 13 28, 10 35, 11 40 C 12 44, 15 47, 21 44 Z" />
|
||||
<path d="M 16 22 C 16 16, 13 11, 16 7 C 20 3, 22 10, 19 16 C 18 19, 17 21, 16 22 Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,39 @@
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.namegen-panel__gender {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.namegen-gender-group {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.namegen-gender-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0.35rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.namegen-gender-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.namegen-gender-btn.is-active {
|
||||
background: var(--color-accent, #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.namegen-panel__usages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,9 +12,17 @@ interface NameSuggestPanelProps {
|
||||
|
||||
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||
|
||||
type GenderSelection = 'any' | 'female' | 'male'
|
||||
|
||||
/** Map the gerbil form's gender onto the generator's three-way choice. */
|
||||
function seedGender(gender: string): GenderSelection {
|
||||
return gender === 'female' || gender === 'male' ? gender : 'any'
|
||||
}
|
||||
|
||||
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||
const t = de.namegen
|
||||
const [letter, setLetter] = useState('')
|
||||
const [genderSel, setGenderSel] = useState<GenderSelection>(() => seedGender(gender))
|
||||
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyMissing, setKeyMissing] = useState(false)
|
||||
@@ -39,7 +47,7 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
|
||||
try {
|
||||
const result = await suggestNames({
|
||||
letter: letter.trim() || undefined,
|
||||
gender: gender || undefined,
|
||||
gender: genderSel === 'any' ? undefined : genderSel,
|
||||
usages: [...usages] as NamegenUsageCode[],
|
||||
count: 6,
|
||||
})
|
||||
@@ -78,6 +86,28 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__gender">
|
||||
<span>{t.genderLabel}</span>
|
||||
<div className="namegen-gender-group" role="radiogroup" aria-label={t.genderLabel}>
|
||||
{([
|
||||
['any', t.genderAny],
|
||||
['female', t.genderFemale],
|
||||
['male', t.genderMale],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={genderSel === value}
|
||||
className={`namegen-gender-btn ${genderSel === value ? 'is-active' : ''}`}
|
||||
onClick={() => setGenderSel(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__usages">
|
||||
<span>{t.usagesLabel}</span>
|
||||
<div className="namegen-usages-grid">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
427
gerbil-manager-web/src/components/gerbilFilterBar.css
Normal file
427
gerbil-manager-web/src/components/gerbilFilterBar.css
Normal file
@@ -0,0 +1,427 @@
|
||||
/* DESIGN (Rennmaus Filter.html): Quick-Chips + Bottom-Sheet + entfernbare Chips.
|
||||
Mobile-first. Palette + font sizes are taken 1:1 from the design (the app tokens
|
||||
are now the design's creme/brown/terracotta), so this matches the prototype. */
|
||||
|
||||
.rmf {
|
||||
--rmf-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- Quick-Chips pill row ---------- */
|
||||
.rmf-quickrow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 4px;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.rmf-quickrow::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.rmf-pill {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
padding: 9px 13px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.12s var(--rmf-ease), border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.rmf-pill:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rmf-pill.is-on {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.rmf-pill-count {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rmf-pill-chev {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* ---------- Active filter chips ---------- */
|
||||
.rmf-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
.rmf-chip {
|
||||
--chip: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: color-mix(in srgb, var(--chip) 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--chip) 32%, transparent);
|
||||
color: color-mix(in srgb, var(--chip) 78%, #2b2723);
|
||||
padding: 6px 8px 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
animation: rmfChipIn 0.22s var(--rmf-ease);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.rmf-chip:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rmf-chip-cat {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.rmf-chip-val {
|
||||
font-weight: 700;
|
||||
}
|
||||
.rmf-chip-x {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--chip) 22%, transparent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rmf-chip-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.rmf-chip-clear .rmf-chip-x {
|
||||
display: none;
|
||||
}
|
||||
@keyframes rmfChipIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Bottom-Sheet ---------- */
|
||||
.rmf-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(43, 33, 25, 0.42);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: rmfScrim 0.2s ease;
|
||||
}
|
||||
@keyframes rmfScrim {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.rmf-sheet {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 22px 22px 0 0;
|
||||
max-height: 82vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -8px 40px rgba(43, 33, 25, 0.25);
|
||||
animation: rmfSheetUp 0.32s var(--rmf-ease);
|
||||
}
|
||||
@keyframes rmfSheetUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.rmf-grip {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-text-faint);
|
||||
opacity: 0.5;
|
||||
margin: 9px auto 4px;
|
||||
}
|
||||
.rmf-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.rmf-sheet-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rmf-sheet-done {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rmf-sheet-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 1rem 1.5rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ---------- Option list (multi-select checkboxes) ---------- */
|
||||
.rmf-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rmf-search {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem 0.875rem;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
margin: 0.25rem 0 0.375rem;
|
||||
}
|
||||
.rmf-search:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.rmf-optlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.rmf-optlist--scroll {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rmf-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
padding: 0.75rem 0.375rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.rmf-opt:hover {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.rmf-opt.is-on {
|
||||
font-weight: 700;
|
||||
}
|
||||
.rmf-opt-box {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
transition: background 0.14s, border-color 0.14s;
|
||||
}
|
||||
.rmf-opt.is-on .rmf-opt-box {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.rmf-opt-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rmf-opt-sym {
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.rmf-opt-label {
|
||||
flex: 1;
|
||||
}
|
||||
.rmf-empty {
|
||||
padding: 1.1rem 0.375rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ---------- Search bar ---------- */
|
||||
.rmf-searchbar {
|
||||
margin-top: 0.75rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.rmf-search-ico {
|
||||
padding-left: 0.875rem;
|
||||
font-size: 19px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.rmf-searchfield {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
color: var(--color-text);
|
||||
padding: 0.875rem 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rmf-searchfield::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.rmf-search-clear {
|
||||
border: none;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-text-muted);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.625rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Bar: Externe-Ahnen toggle + Sortieren — two equal-width chips ---------- */
|
||||
.rm-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
/* Shared pill: both chips are the SAME size (flex: 1 → equal width). */
|
||||
.rmf-bar-chip {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
.rmf-bar-chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
/* Externe-Ahnen active state. */
|
||||
.rmf-bar-chip.is-on {
|
||||
background: var(--color-accent-soft);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 32%, transparent);
|
||||
color: color-mix(in srgb, var(--color-accent) 78%, #2b2723);
|
||||
}
|
||||
.rmf-bar-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rmf-bar-chip-ico {
|
||||
color: var(--color-accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
.rmf-bar-chip-chev {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* Toggle icon circle on the Externe-Ahnen chip (+ → ✓). */
|
||||
.rmf-chip-toggle-ico {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 800;
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-accent);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rmf-bar-chip.is-on .rmf-chip-toggle-ico {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Desktop: the sheet becomes a centered modal rather than a bottom drawer. */
|
||||
@media (min-width: 768px) {
|
||||
.rmf-scrim {
|
||||
align-items: center;
|
||||
}
|
||||
.rmf-sheet {
|
||||
border-radius: 18px;
|
||||
max-width: 420px;
|
||||
max-height: 70vh;
|
||||
animation: rmfChipIn 0.2s var(--rmf-ease);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
fromJSON,
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
displayGenotypeSafe,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
@@ -46,8 +47,8 @@ describe('Fraction', () => {
|
||||
describe('Genotype serialization', () => {
|
||||
it('round-trips display string <-> structured form', () => {
|
||||
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
|
||||
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
||||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
||||
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||
})
|
||||
|
||||
it('parses multi-char C-series alleles via maximal munch', () => {
|
||||
@@ -72,8 +73,8 @@ describe('Genotype serialization', () => {
|
||||
expect(g.P).toEqual(['P', 'p'])
|
||||
})
|
||||
|
||||
it('wild type is AA CC DD EE GG PP spsp rere', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +103,7 @@ describe('Worked example from research report', () => {
|
||||
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
const only = result.offspring[0]
|
||||
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
|
||||
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
|
||||
expect(only.probability.text).toBe('1')
|
||||
expect(result.warnings).toHaveLength(0)
|
||||
})
|
||||
@@ -223,8 +224,9 @@ describe('Farbschlag catalog', () => {
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
||||
// Every row has a non-empty canonical genotype display string and unique name.
|
||||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
|
||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
})
|
||||
|
||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||
@@ -285,7 +287,7 @@ describe('GEN-3a: Uw=G alias', () => {
|
||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||
'AA CC DD EE Gg PP spsp rere',
|
||||
'AA CC DD EE Gg PP spsp',
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||
})
|
||||
@@ -297,10 +299,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
||||
})
|
||||
|
||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
|
||||
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||
// Rex het → Rere shown
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
|
||||
'AA CC DD EE GG PP spsp Rere',
|
||||
)
|
||||
// WP → Slsl shown, rere still omitted
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||||
'AA CC DD EE GG PP spsp rere Slsl',
|
||||
'AA CC DD EE GG PP spsp Slsl',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -330,7 +338,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
|
||||
})
|
||||
|
||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||
@@ -363,11 +371,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
|
||||
})
|
||||
it("'?' and '-' inputs are equivalent", () => {
|
||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||
'Aa C- DD EE GG Pp spsp rere',
|
||||
'Aa C- DD EE GG Pp spsp',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -590,15 +598,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||
// Fuchsschimmel: E=[ef,ef] hom
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||
'AA CC DD e[f]e[f] GG PP spsp rere',
|
||||
'AA CC DD e[f]e[f] GG PP spsp',
|
||||
)
|
||||
// C-locus het: cchm + ch
|
||||
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
||||
'aa c[chm]c[h] DD EE GG PP spsp',
|
||||
)
|
||||
// C-locus hom cchm
|
||||
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
||||
'aa c[chm]c[chm] DD EE GG PP spsp',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -608,24 +616,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
// Display must swap to [e, ef] per breeder convention.
|
||||
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||||
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
|
||||
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
|
||||
})
|
||||
|
||||
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||
'aa CC DD Ee GG PP spsp rere',
|
||||
'aa CC DD Ee GG PP spsp',
|
||||
)
|
||||
})
|
||||
|
||||
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||
'aa CC DD Ee[f] GG PP spsp rere',
|
||||
'aa CC DD Ee[f] GG PP spsp',
|
||||
)
|
||||
})
|
||||
|
||||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||||
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
|
||||
const display = 'aa C- D- ee[f] Gg Pp spsp'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.E).toEqual(['ef', 'e'])
|
||||
expect(g.C).toEqual(['C', '?'])
|
||||
@@ -633,16 +641,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||||
})
|
||||
|
||||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
||||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
||||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
|
||||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['cchm', 'ch'])
|
||||
expect(g.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
})
|
||||
|
||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
||||
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
|
||||
const display = 'aa Cc[h] dd EE Gg P- Spsp'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
expect(g.D).toEqual(['d', 'd'])
|
||||
@@ -665,7 +673,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
||||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||
expect(g.E).toEqual(['e', '?'])
|
||||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
||||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
|
||||
})
|
||||
|
||||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||||
@@ -677,6 +685,50 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||
expect(g.D).toEqual(['D', 'd'])
|
||||
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
|
||||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
|
||||
})
|
||||
|
||||
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
|
||||
it('handles blank c locus and c[-] resiliently without crashing', () => {
|
||||
// Vandana's genotype
|
||||
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
|
||||
const g = fromDisplayString(rawVandana)
|
||||
expect(g.C).toEqual(['C', '?'])
|
||||
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
|
||||
|
||||
// cchmc allele
|
||||
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
|
||||
expect(g2.C).toEqual(['cchm', '?'])
|
||||
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
|
||||
|
||||
// c[-] and blank c standalone
|
||||
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
|
||||
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
|
||||
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
|
||||
|
||||
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
|
||||
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
|
||||
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
|
||||
})
|
||||
|
||||
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
|
||||
// 1. Parseable with unknown alleles, replacing ? with -
|
||||
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
|
||||
|
||||
// 2. Parseable with trailing ?? (stripped completely)
|
||||
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
|
||||
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
|
||||
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
|
||||
|
||||
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
|
||||
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
|
||||
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
|
||||
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
|
||||
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
|
||||
|
||||
// 4. Handles null/undefined/empty
|
||||
expect(displayGenotypeSafe(null)).toBe('')
|
||||
expect(displayGenotypeSafe(undefined)).toBe('')
|
||||
expect(displayGenotypeSafe('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,425 +2,425 @@
|
||||
{
|
||||
"name": "REW",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp",
|
||||
"sortOrder": 5,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"english": "Black",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp",
|
||||
"sortOrder": 7,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"english": "Slate",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||
"sortOrder": 9,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"english": "Blue",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 11,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"english": "Lilac",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||
"sortOrder": 13,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"english": "Argente Nutmeg",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp",
|
||||
"sortOrder": 16,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp",
|
||||
"sortOrder": 18,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp",
|
||||
"sortOrder": 20,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Agouti",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Silberagouti",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Anthrazit",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Algierfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp",
|
||||
"sortOrder": 32
|
||||
},
|
||||
{
|
||||
"name": "Dilute Goldfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd ee GG pp spsp",
|
||||
"sortOrder": 33
|
||||
},
|
||||
{
|
||||
"name": "Dilute Rotfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee GG pp spsp",
|
||||
"sortOrder": 34
|
||||
},
|
||||
{
|
||||
"name": "Dilute Polarfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd ee gg PP spsp",
|
||||
"sortOrder": 35
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||
"sortOrder": 36,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||
"sortOrder": 42,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 44,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||
"sortOrder": 45,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||
"sortOrder": 47,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||
"sortOrder": 49,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 50,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||
"sortOrder": 51,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Topas",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||
"sortOrder": 52,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp",
|
||||
"sortOrder": 54,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 55,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp",
|
||||
"sortOrder": 56,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp",
|
||||
"sortOrder": 57,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp",
|
||||
"sortOrder": 59,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp",
|
||||
"sortOrder": 61,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp",
|
||||
"sortOrder": 63,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp",
|
||||
"sortOrder": 65
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp",
|
||||
"sortOrder": 66
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp",
|
||||
"sortOrder": 67
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 68
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 69
|
||||
}
|
||||
]
|
||||
|
||||
@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||
* convention) — e.g. ['C','?'] renders "C-".
|
||||
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
||||
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
|
||||
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
|
||||
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
|
||||
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
|
||||
* 8-locus strings and the colour catalog stay byte-identical; it only appears for
|
||||
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
|
||||
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
|
||||
* convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
|
||||
* order E > e > e[f].
|
||||
*/
|
||||
export function toDisplayString(g: Genotype): string {
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
(locus) =>
|
||||
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
|
||||
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => {
|
||||
const [a, b] = displayPair(locus, g[locus])
|
||||
@@ -189,6 +192,11 @@ function normalizeToken(tok: string): string | null {
|
||||
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
|
||||
// leaving the leading allele intact for splitToken).
|
||||
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
||||
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
||||
t = t.replace(/^c\[-\]$/g, '??')
|
||||
t = t.replace(/^c\?$/g, '??')
|
||||
t = t.replace(/^c$/g, '??')
|
||||
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
|
||||
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
|
||||
t = t.replace(/-/g, '?')
|
||||
@@ -254,6 +262,25 @@ export function fromDisplayString(input: string): Genotype {
|
||||
return makeGenotype({ ...base, ...acc })
|
||||
}
|
||||
|
||||
export function displayGenotypeSafe(raw: string | null | undefined): string {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const parsed = fromDisplayString(raw)
|
||||
return toDisplayString(parsed)
|
||||
} catch {
|
||||
// Fallback path:
|
||||
return raw
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((tok) => {
|
||||
const norm = normalizeToken(tok)
|
||||
return norm !== null && norm !== '?' && norm !== '??'
|
||||
})
|
||||
.map((tok) => tok.replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUnknown(g: Genotype): boolean {
|
||||
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
toJSON,
|
||||
fromJSON,
|
||||
hasUnknown,
|
||||
displayGenotypeSafe,
|
||||
WILDCARD,
|
||||
} from './genotype'
|
||||
export type { Genotype, AllelePair } from './genotype'
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface AsyncState<T> {
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
function messageFor(err: unknown): string {
|
||||
export function messageFor(err: unknown): string {
|
||||
if (err instanceof ApiError) return err.message
|
||||
if (err instanceof Error && err.message) return err.message
|
||||
return de.api.errors.unknown
|
||||
|
||||
151
gerbil-manager-web/src/hooks/useInfiniteList.ts
Normal file
151
gerbil-manager-web/src/hooks/useInfiniteList.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Server-paginated infinite list (no react-query).
|
||||
*
|
||||
* `loader(page)` fetches ONE page; pages are accumulated into `items`. The list
|
||||
* never loads everything up front — `loadMore()` (wired to a scroll sentinel)
|
||||
* advances exactly one page at a time until `hasMore` is false.
|
||||
*
|
||||
* Changing `resetKey` (e.g. the filter/sort/search signature) clears the list and
|
||||
* reloads from page 1. `reload()` re-fetches page 1 on demand (e.g. retry).
|
||||
*
|
||||
* Design mirrors useApi (and satisfies the strict react-hooks rules):
|
||||
* - the request signature lives in `req`; the query reset on `resetKey` change is
|
||||
* an in-render state adjustment (not a setState-in-effect), so no cascade.
|
||||
* - `loading` / `loadingMore` are DERIVED from resolved-vs-current request keys,
|
||||
* never set synchronously inside the fetch effect.
|
||||
* - the loader is read through a ref so the effect keys purely on the request id.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { messageFor } from './useApi'
|
||||
import type { Paged } from '../api/types'
|
||||
|
||||
export interface InfiniteListState<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
/** Initial / reset load (page 1) in flight. */
|
||||
loading: boolean
|
||||
/** A subsequent page is being appended. */
|
||||
loadingMore: boolean
|
||||
error: string | null
|
||||
hasMore: boolean
|
||||
/** Advance one page; the scroll sentinel calls this. */
|
||||
loadMore: () => void
|
||||
/** Re-fetch from page 1 (retry). */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
interface Req {
|
||||
key: string
|
||||
page: number
|
||||
nonce: number
|
||||
}
|
||||
|
||||
interface Resolved<T> {
|
||||
reqId: string
|
||||
items: T[]
|
||||
total: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useInfiniteList<T>(
|
||||
loader: (page: number) => Promise<Paged<T>>,
|
||||
resetKey: string,
|
||||
): InfiniteListState<T> {
|
||||
const loaderRef = useRef(loader)
|
||||
useEffect(() => {
|
||||
loaderRef.current = loader
|
||||
})
|
||||
|
||||
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
|
||||
// Adjust state during render when the query signature changes — the recommended
|
||||
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
|
||||
if (req.key !== resetKey) {
|
||||
setReq({ key: resetKey, page: 1, nonce: 0 })
|
||||
}
|
||||
|
||||
const queryId = `${req.nonce}:${req.key}`
|
||||
const reqId = `${queryId}#${req.page}`
|
||||
|
||||
const [resolved, setResolved] = useState<Resolved<T>>({
|
||||
reqId: '',
|
||||
items: [],
|
||||
total: 0,
|
||||
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) => {
|
||||
if (cancelled) return
|
||||
setResolved((prev) => ({
|
||||
reqId: id,
|
||||
// page 1 replaces (fresh query); later pages append.
|
||||
items: page === 1 ? res.items : [...prev.items, ...res.items],
|
||||
total: res.totalCount,
|
||||
error: null,
|
||||
}))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) inFlightRef.current = false
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [req]) // req is a fresh object per change (key/page/nonce) → one fetch per change
|
||||
|
||||
// Resolved data only counts if it belongs to the current query signature.
|
||||
const resolvedQueryId = resolved.reqId.slice(0, resolved.reqId.lastIndexOf('#'))
|
||||
const sameQuery = resolved.reqId !== '' && resolvedQueryId === queryId
|
||||
const inFlight = resolved.reqId !== reqId
|
||||
|
||||
const items = sameQuery ? resolved.items : []
|
||||
const total = sameQuery ? resolved.total : 0
|
||||
const loading = !sameQuery || (inFlight && req.page === 1)
|
||||
const loadingMore = sameQuery && inFlight && req.page > 1
|
||||
const error = inFlight ? null : sameQuery ? resolved.error : null
|
||||
const hasMore = sameQuery && !inFlight && items.length < total
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,15 +2,20 @@
|
||||
Seitenleiste links ab 768px (Laptop). */
|
||||
|
||||
:root {
|
||||
--color-bg: #faf7f2;
|
||||
/* DESIGN (Rennmaus Filter): the app palette is the design's creme/brown/terracotta. */
|
||||
--color-bg: #f6efe2;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e5ddd0;
|
||||
--color-text: #3b3026;
|
||||
--color-text-muted: #8a7d6b;
|
||||
--color-accent: #b5651d;
|
||||
--color-accent-soft: #f6e7d7;
|
||||
--color-border: #e7dcc8;
|
||||
--color-text: #3d2e23;
|
||||
--color-text-muted: #8c7f6e;
|
||||
--color-text-faint: #b4a790;
|
||||
--color-accent: #a85f2e;
|
||||
--color-accent-soft: #f3e7da;
|
||||
/* DESIGN (Rennmaus Filter): the single soft elevation used on cards, pills,
|
||||
the search bar and buttons. */
|
||||
--shadow: 0 1px 2px rgba(61, 46, 35, 0.06), 0 2px 8px rgba(61, 46, 35, 0.05);
|
||||
|
||||
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Hanken Grotesk', system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
@@ -41,46 +46,41 @@ a {
|
||||
|
||||
/* --- Grundgerüst (Smartphone) --- */
|
||||
|
||||
/* DESIGN: the top "Rennmaus-Manager" title bar is removed; on mobile only the
|
||||
bottom action bar remains, on desktop the left sidebar.
|
||||
The shell is a fixed viewport box and ONLY the main area scrolls — so the bottom
|
||||
nav (mobile) and the sidebar (desktop) stay put, and the page header's
|
||||
position: sticky pins reliably to the top of the scrolling main area. */
|
||||
.app-shell {
|
||||
display: grid;
|
||||
min-height: 100dvh;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'main'
|
||||
'nav';
|
||||
}
|
||||
|
||||
.app-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-area: main;
|
||||
padding: 1rem;
|
||||
/* No top padding: the page's sticky header pins flush to the top with no gap
|
||||
(pages add their own top spacing via .page). */
|
||||
padding: 0 1rem 1.5rem;
|
||||
max-width: 60rem;
|
||||
width: 100%;
|
||||
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
|
||||
the page body scroll horizontally. The .genotype-table-scroll wrapper
|
||||
provides the per-table horizontal scroll. */
|
||||
/* MAIN is the scroll container: min-height:0 lets the 1fr grid row shrink below
|
||||
its content so it can scroll. overflow-anchor:none stops the browser from
|
||||
re-adjusting scroll when the sticky title collapses (no scroll "fight"). */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* Top spacing for page content (replaces the removed .app-main top padding). The
|
||||
Rennmäuse page opts out — its sticky header provides its own top padding. */
|
||||
.page {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
/* Tab-Leiste unten */
|
||||
@@ -92,6 +92,8 @@ a {
|
||||
border-top: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
/* Above the sticky page header + cards so it is never overlapped. */
|
||||
z-index: 30;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
@@ -122,10 +124,8 @@ a {
|
||||
@media (min-width: 768px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 14rem 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'header header'
|
||||
'nav main';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: 'nav main';
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
@@ -136,6 +136,9 @@ a {
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 1rem 0.5rem;
|
||||
position: static;
|
||||
/* Sidebar stays fixed; scrolls internally only if the link list is taller
|
||||
than the viewport. */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@@ -156,7 +159,7 @@ a {
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1.5rem 2rem;
|
||||
padding: 0 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,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>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { listLitters as listLittersPaged } from '../api/litters'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import Charakterbogen from '../components/Charakterbogen'
|
||||
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
|
||||
@@ -22,10 +22,10 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
|
||||
if (!genotype || !genotype.trim()) return null
|
||||
try {
|
||||
const g = fromDisplayString(genotype)
|
||||
return { display: toDisplayString(g), farbschlag: genotypeToFarbschlag(g) }
|
||||
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
|
||||
} catch {
|
||||
// Unparseable: show the raw value, no derived Farbschlag.
|
||||
return { display: genotype, farbschlag: de.genetics.unknownFarbschlag }
|
||||
// Unparseable: show the safely-rendered raw value, no derived Farbschlag.
|
||||
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, GERBIL_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 } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import ColorVarietyPicker from '../components/ColorVarietyPicker'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
@@ -29,12 +29,15 @@ interface FormState {
|
||||
notes: string
|
||||
isResident: boolean
|
||||
isDeaf: boolean | null
|
||||
spottingType: string
|
||||
isSchecke: boolean
|
||||
isCastrated: boolean
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
gender: '',
|
||||
status: 'Active',
|
||||
gender: 'unknown',
|
||||
status: 'Breeding',
|
||||
dateOfBirth: '',
|
||||
dateOfDeath: '',
|
||||
causeOfDeath: '',
|
||||
@@ -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,31 +243,61 @@ 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.status}</span>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
|
||||
{GERBIL_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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">
|
||||
@@ -266,6 +310,133 @@ export default function GerbilFormPage() {
|
||||
/>
|
||||
</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">
|
||||
<span>{t.fields.status}</span>
|
||||
<span className="muted">
|
||||
{statusLabel(form.status)}
|
||||
<small className="muted" style={{ marginLeft: '0.5rem' }}>
|
||||
({t.form.statusDerivedHint[form.status]})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{t.fields.status}</span>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
|
||||
{SELECTABLE_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form.status === 'Deceased' && (
|
||||
<>
|
||||
<label className="field">
|
||||
@@ -302,24 +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) => set('colorVarietyId', e.target.value)}
|
||||
>
|
||||
<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)}>
|
||||
@@ -344,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>
|
||||
@@ -373,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>
|
||||
@@ -381,39 +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) => set('genotype', e.target.value)}
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -21,14 +21,15 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import Tree from 'react-d3-tree'
|
||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import { API_BASE_URL, ApiError } from '../api/client'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { getInbreedingCoefficient } from '../api/pedigree'
|
||||
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||
import {
|
||||
DEFAULT_GENERATIONS,
|
||||
ancestorsAt,
|
||||
@@ -379,7 +380,14 @@ function PedigreeCard({
|
||||
const t = de.pages.stammbaum
|
||||
const g = node.gerbil
|
||||
const chip = farbschlag ? chipColorFor(farbschlag) : null
|
||||
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null
|
||||
const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
? g.profilePhotoUrl
|
||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||
: null
|
||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
||||
return (
|
||||
<div
|
||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||
@@ -387,9 +395,17 @@ function PedigreeCard({
|
||||
role={isRoot ? undefined : 'button'}
|
||||
title={isRoot ? undefined : t.tapHint}
|
||||
>
|
||||
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
|
||||
<div className="pedigree-card__photo" aria-hidden="true">
|
||||
🐭
|
||||
<div className="pedigree-card__photo" aria-hidden={!photoUrl || undefined}>
|
||||
{photoUrl ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="pedigree-card__avatar"
|
||||
onError={() => setFailedUrl(rawUrl)}
|
||||
/>
|
||||
) : (
|
||||
<GerbilIcon size="1.6rem" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pedigree-card__body">
|
||||
<div className="pedigree-card__name">
|
||||
@@ -406,11 +422,12 @@ function PedigreeCard({
|
||||
<span
|
||||
className="pedigree-chip"
|
||||
style={chip ? { background: chip.bg, color: chip.fg } : undefined}
|
||||
title={farbschlag}
|
||||
>
|
||||
{farbschlag}
|
||||
{g.genotype ? displayGenotypeSafe(g.genotype) : farbschlag}
|
||||
</span>
|
||||
)}
|
||||
{year && <span className="pedigree-card__year">* {year}</span>}
|
||||
{dob && <span className="pedigree-card__year">* {dob}</span>}
|
||||
</div>
|
||||
{node.expandable && (
|
||||
<button
|
||||
@@ -555,7 +572,17 @@ function PrintCell({
|
||||
</div>
|
||||
)}
|
||||
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
|
||||
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
|
||||
{g.genotype && gen <= 2 && (
|
||||
<div className="stammbaum-print__geno">
|
||||
{(() => {
|
||||
try {
|
||||
return displayGenotypeSafe(g.genotype)
|
||||
} catch {
|
||||
return g.genotype.replace(/\?/g, '-')
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user