Compare commits
15 Commits
feature/we
...
feature/ux
| Author | SHA1 | Date | |
|---|---|---|---|
| d116b8d8f1 | |||
| a2f5e848d1 | |||
| de293904bb | |||
| 68447896ee | |||
| 5f26594a12 | |||
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| c357a18418 | |||
| 791e9f91cc | |||
| efce79b3fa | |||
| 868c5f5cd7 | |||
| ddfa3614a4 | |||
| 13da8f2502 | |||
| 13eb17b453 |
@@ -11,21 +11,17 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Snapshot_has_site_nav_and_seeded_pages()
|
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
|
||||||
{
|
{
|
||||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||||
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
|
|
||||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||||
|
|
||||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||||
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
|
Assert.Equal(6, pages.Count);
|
||||||
Assert.Equal(8, pages.Count);
|
|
||||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
|
|
||||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
|
|
||||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||||
|
|||||||
@@ -507,6 +507,44 @@ namespace GerbilManager.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||||
|
{
|
||||||
|
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||||
|
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// One dated litter, one undated litter (blank date field)
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||||
|
[
|
||||||
|
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||||
|
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||||
|
|
||||||
|
using var db = NewDb();
|
||||||
|
|
||||||
|
// First run
|
||||||
|
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(2, r1.Litters.InSource);
|
||||||
|
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||||
|
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||||
|
|
||||||
|
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||||
|
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||||
|
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||||
[InlineData("5.3.21", 2021, 3, 5)]
|
[InlineData("5.3.21", 2021, 3, 5)]
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||||
Assert.Contains("NIEMALS Preise", prompt);
|
Assert.Contains("NIEMALS Preise", prompt);
|
||||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||||
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
|
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
|
||||||
|
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
|
||||||
Assert.Contains("Status: FREI", prompt);
|
Assert.Contains("Status: FREI", prompt);
|
||||||
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
|
|
||||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,10 @@ public class SiteRendererTests
|
|||||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static JsonObject AbgabetiereBlock(string intro,
|
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
|
||||||
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
|
|
||||||
{
|
{
|
||||||
var animalArr = new JsonArray();
|
var animalArr = new JsonArray();
|
||||||
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
|
foreach (var (name, farbe, group, photo) in animals)
|
||||||
{
|
{
|
||||||
var photos = new JsonArray();
|
var photos = new JsonArray();
|
||||||
if (photo is not null) photos.Add(photo);
|
if (photo is not null) photos.Add(photo);
|
||||||
@@ -82,9 +81,6 @@ public class SiteRendererTests
|
|||||||
["name"] = name,
|
["name"] = name,
|
||||||
["farbschlag"] = farbe,
|
["farbschlag"] = farbe,
|
||||||
["group"] = group,
|
["group"] = group,
|
||||||
["gender"] = gender,
|
|
||||||
["dateOfBirth"] = dob,
|
|
||||||
["characterNote"] = note,
|
|
||||||
["photos"] = photos,
|
["photos"] = photos,
|
||||||
["aiSaleText"] = (JsonNode?)null,
|
["aiSaleText"] = (JsonNode?)null,
|
||||||
});
|
});
|
||||||
@@ -96,7 +92,6 @@ public class SiteRendererTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -216,52 +211,16 @@ public class SiteRendererTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
|
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
||||||
{
|
{
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
|
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
Assert.Contains("Krümel", html);
|
Assert.Contains("Krümel", html);
|
||||||
Assert.Contains("CP-Agouti", html);
|
Assert.Contains("CP-Agouti", html);
|
||||||
Assert.Contains("Großbecken", html);
|
Assert.Contains("Großbecken", html);
|
||||||
Assert.Contains("/photos/files/abc.jpg", html);
|
Assert.Contains("/photos/files/abc.jpg", html);
|
||||||
Assert.Contains("cms-group-card", html);
|
Assert.Contains("cms-animal-card", html);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
|
||||||
Blocks(AbgabetiereBlock("",
|
|
||||||
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
|
|
||||||
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
|
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
|
||||||
Assert.Contains("Weibchen", html);
|
|
||||||
Assert.Contains("Verfügbar", html);
|
|
||||||
Assert.Contains("cms-badge--free", html);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
|
||||||
Blocks(AbgabetiereBlock("",
|
|
||||||
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
|
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
|
||||||
Assert.Contains("März 2024", html);
|
|
||||||
Assert.Contains("Sehr neugierig und verspielt.", html);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
|
||||||
Blocks(AbgabetiereBlock("",
|
|
||||||
("Einzelkind", "Zobel", null, null, "male", null, null)))));
|
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
|
||||||
Assert.Contains("Einzelkind", html);
|
|
||||||
Assert.Contains("Männchen", html);
|
|
||||||
Assert.Contains("cms-group-card", html);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -324,44 +283,6 @@ public class SiteRendererTests
|
|||||||
Assert.Equal(7, files.Count);
|
Assert.Equal(7, files.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Render_impressum_and_datenschutz_pages_are_rendered()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(
|
|
||||||
("start", "Startseite", Blocks()),
|
|
||||||
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
|
|
||||||
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
|
|
||||||
var files = SiteRenderer.Render(snap);
|
|
||||||
Assert.True(files.ContainsKey("impressum/index.html"));
|
|
||||||
Assert.True(files.ContainsKey("datenschutz/index.html"));
|
|
||||||
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Footer_contains_impressum_and_datenschutz_links()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(
|
|
||||||
("start", "Startseite", Blocks()),
|
|
||||||
("kontakt", "Kontakt", Blocks()));
|
|
||||||
var startHtml = SiteRenderer.Render(snap)["index.html"];
|
|
||||||
Assert.Contains("impressum/index.html", startHtml);
|
|
||||||
Assert.Contains("datenschutz/index.html", startHtml);
|
|
||||||
// links in footer, not nav
|
|
||||||
Assert.Contains("site-footer-link", startHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Footer_links_use_correct_relative_path_from_subpage()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(
|
|
||||||
("start", "Startseite", Blocks()),
|
|
||||||
("kontakt", "Kontakt", Blocks()));
|
|
||||||
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
|
|
||||||
// subpage needs ../ prefix for footer links
|
|
||||||
Assert.Contains("../impressum/index.html", kontaktHtml);
|
|
||||||
Assert.Contains("../datenschutz/index.html", kontaktHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -193,15 +193,6 @@ public class ApplicationContext : DbContext
|
|||||||
(6, "kontakt", "Kontakt"),
|
(6, "kontakt", "Kontakt"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// WEB-6a: footer-only legal pages (not in main nav)
|
|
||||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
|
||||||
{
|
|
||||||
(7, "impressum", "Impressum",
|
|
||||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
|
||||||
(8, "datenschutz", "Datenschutz",
|
|
||||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var pageRows = new List<Page>();
|
var pageRows = new List<Page>();
|
||||||
var blockRows = new List<Block>();
|
var blockRows = new List<Block>();
|
||||||
foreach (var p in pages)
|
foreach (var p in pages)
|
||||||
@@ -220,12 +211,6 @@ public class ApplicationContext : DbContext
|
|||||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||||
});
|
});
|
||||||
foreach (var lp in legalPages)
|
|
||||||
{
|
|
||||||
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
|
|
||||||
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
|
|
||||||
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
|
|
||||||
}
|
|
||||||
|
|
||||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||||
|
|||||||
@@ -131,8 +131,7 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
private static string RenderAbgabetiereList(JsonObject d)
|
private static string RenderAbgabetiereList(JsonObject d)
|
||||||
{
|
{
|
||||||
var intro = Str(d["intro"]);
|
var intro = Str(d["intro"]);
|
||||||
var animals = (d["animals"] as JsonArray ?? [])
|
var animals = d["animals"] as JsonArray ?? [];
|
||||||
.OfType<JsonObject>().ToList();
|
|
||||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(intro))
|
if (!string.IsNullOrWhiteSpace(intro))
|
||||||
@@ -141,80 +140,39 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
if (animals.Count == 0)
|
if (animals.Count == 0)
|
||||||
{
|
{
|
||||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||||
sb.Append("</section>\n");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// Group by Becken name; null group → solo card keyed by animal name
|
|
||||||
var groups = animals
|
|
||||||
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
sb.Append(" <div class=\"cms-group-list\">\n");
|
|
||||||
foreach (var grp in groups)
|
|
||||||
{
|
{
|
||||||
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
||||||
var groupName = isSolo ? null : grp.Key;
|
foreach (var node in animals)
|
||||||
var memberList = grp.ToList();
|
|
||||||
|
|
||||||
// Determine group gender label
|
|
||||||
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
|
||||||
string genderLabel = genders.Count == 1
|
|
||||||
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
|
||||||
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
|
||||||
|
|
||||||
sb.Append(" <article class=\"cms-group-card\">\n");
|
|
||||||
sb.Append(" <div class=\"cms-group-header\">\n");
|
|
||||||
if (!string.IsNullOrEmpty(groupName))
|
|
||||||
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
|
||||||
var metaParts = new List<string>();
|
|
||||||
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
|
||||||
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
|
||||||
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
|
||||||
sb.Append(" </div>\n");
|
|
||||||
|
|
||||||
// Gallery: first photo of each animal in the group
|
|
||||||
var gallery = memberList
|
|
||||||
.Select(a => (
|
|
||||||
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
|
||||||
name: Str(a["name"]) ?? ""))
|
|
||||||
.Where(x => !string.IsNullOrEmpty(x.url))
|
|
||||||
.ToList();
|
|
||||||
if (gallery.Count > 0)
|
|
||||||
{
|
|
||||||
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
|
||||||
foreach (var (url, name) in gallery)
|
|
||||||
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
|
||||||
sb.Append(" </div>\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-animal detail rows
|
|
||||||
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
|
||||||
foreach (var a in memberList)
|
|
||||||
{
|
{
|
||||||
|
if (node is not JsonObject a) continue;
|
||||||
var name = H(Str(a["name"]) ?? "");
|
var name = H(Str(a["name"]) ?? "");
|
||||||
var farbe = Str(a["farbschlag"]);
|
var farbe = H(Str(a["farbschlag"]) ?? "");
|
||||||
var dob = Str(a["dateOfBirth"]);
|
var group = Str(a["group"]);
|
||||||
var note = Str(a["characterNote"]);
|
|
||||||
var saleText = Str(a["aiSaleText"]);
|
var saleText = Str(a["aiSaleText"]);
|
||||||
|
var photos = a["photos"] as JsonArray ?? [];
|
||||||
|
|
||||||
sb.Append(" <li class=\"cms-animal-row\">\n");
|
sb.Append(" <article class=\"cms-animal-card\">\n");
|
||||||
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
|
||||||
var details = new List<string>();
|
// Profile photo (first photo)
|
||||||
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
||||||
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
if (!string.IsNullOrEmpty(firstPhoto))
|
||||||
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
||||||
if (details.Count > 0)
|
|
||||||
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
sb.Append(" <div class=\"cms-animal-info\">\n");
|
||||||
var personalityText = note ?? saleText;
|
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
||||||
if (!string.IsNullOrWhiteSpace(personalityText))
|
if (!string.IsNullOrEmpty(farbe))
|
||||||
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
||||||
sb.Append(" </li>\n");
|
if (!string.IsNullOrWhiteSpace(group))
|
||||||
}
|
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
||||||
sb.Append(" </ul>\n");
|
if (!string.IsNullOrWhiteSpace(saleText))
|
||||||
|
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
||||||
|
sb.Append(" </div>\n");
|
||||||
sb.Append(" </article>\n");
|
sb.Append(" </article>\n");
|
||||||
}
|
}
|
||||||
sb.Append(" </div>\n");
|
sb.Append(" </div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
sb.Append("</section>\n");
|
sb.Append("</section>\n");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -254,10 +212,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||||
<nav class="site-footer-nav" aria-label="Rechtliches">
|
|
||||||
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
|
||||||
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -274,8 +228,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
["abgabebedingungen"] = "Abgabebedingungen",
|
["abgabebedingungen"] = "Abgabebedingungen",
|
||||||
["farben-genetik"] = "Farben & Genetik",
|
["farben-genetik"] = "Farben & Genetik",
|
||||||
["kontakt"] = "Kontakt",
|
["kontakt"] = "Kontakt",
|
||||||
["impressum"] = "Impressum",
|
|
||||||
["datenschutz"] = "Datenschutz",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -384,256 +336,121 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
internal static string SiteCss() => """
|
internal static string SiteCss() => """
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #fdf7f0;
|
--color-bg: #fdf8f3;
|
||||||
--color-surface: #fff;
|
--color-surface: #fff;
|
||||||
--color-surface-warm: #fef9f4;
|
--color-text: #2c2c2c;
|
||||||
--color-text: #2a2118;
|
--color-muted: #6b6b6b;
|
||||||
--color-muted: #7a6d62;
|
--color-accent: #c0392b;
|
||||||
--color-accent: #b5331a;
|
--color-border: #e0d8d0;
|
||||||
--color-accent-hover: #8f2815;
|
--font-body: system-ui, sans-serif;
|
||||||
--color-accent-soft: #fdf0ed;
|
--max-w: 860px;
|
||||||
--color-border: #e8ddd4;
|
|
||||||
--color-badge-free: #2d7a4a;
|
|
||||||
--color-badge-free-bg: #e6f4ec;
|
|
||||||
--font-body: Georgia, "Times New Roman", serif;
|
|
||||||
--font-ui: system-ui, -apple-system, sans-serif;
|
|
||||||
--max-w: 900px;
|
|
||||||
--radius: 10px;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.7;
|
line-height: 1.65;
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
a { color: var(--color-accent); text-decoration: none; }
|
a { color: var(--color-accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
a:hover { text-decoration: underline; }
|
||||||
img { max-width: 100%; height: auto; display: block; }
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
p { margin-bottom: .75rem; }
|
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 2px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding: 0 1rem;
|
padding: .75rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
flex-wrap: wrap;
|
||||||
gap: 0;
|
align-items: center;
|
||||||
min-height: 56px;
|
gap: .5rem 1.5rem;
|
||||||
}
|
}
|
||||||
.site-logo {
|
.site-logo {
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.15rem;
|
font-size: 1.1rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-right: 1.5rem;
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
text-decoration: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
||||||
.site-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding-left: .5rem;
|
|
||||||
}
|
|
||||||
.site-nav::-webkit-scrollbar { display: none; }
|
|
||||||
.site-nav-link {
|
.site-nav-link {
|
||||||
font-family: var(--font-ui);
|
font-size: .9rem;
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
padding: .5rem .65rem;
|
padding: .2rem .4rem;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
|
||||||
transition: color .15s, background .15s;
|
|
||||||
}
|
}
|
||||||
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
|
||||||
.site-nav-link[aria-current="page"] {
|
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
background: var(--color-accent-soft);
|
background: #fef0ee;
|
||||||
font-weight: 600;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
.site-main {
|
.site-main {
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
margin: 2.5rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem 4rem;
|
padding: 0 1rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
background: var(--color-surface);
|
border-top: 1px solid var(--color-border);
|
||||||
border-top: 2px solid var(--color-border);
|
padding: 1.5rem 1rem;
|
||||||
padding: 1.75rem 1rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
|
||||||
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
|
||||||
.site-footer-link:hover { color: var(--color-accent); }
|
|
||||||
|
|
||||||
/* ── CMS blocks ── */
|
/* ── CMS blocks ── */
|
||||||
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
.cms-heading { margin: 1.5rem 0 .5rem; }
|
||||||
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
h1.cms-heading { font-size: 1.8rem; }
|
||||||
h2.cms-heading { font-size: 1.5rem; }
|
h2.cms-heading { font-size: 1.4rem; }
|
||||||
h3.cms-heading { font-size: 1.2rem; }
|
|
||||||
.cms-richtext { margin: 1rem 0; }
|
.cms-richtext { margin: 1rem 0; }
|
||||||
.cms-richtext p { margin-bottom: .75rem; }
|
.cms-richtext p { margin-bottom: .75rem; }
|
||||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||||
.cms-richtext li { margin-bottom: .35rem; }
|
.cms-richtext li { margin-bottom: .3rem; }
|
||||||
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
.cms-image { margin: 1.5rem 0; }
|
||||||
.cms-image { margin: 1.75rem 0; }
|
.cms-image img { border-radius: 6px; }
|
||||||
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
|
||||||
.cms-gallery {
|
.cms-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
margin: 1.75rem 0;
|
margin: 1.5rem 0;
|
||||||
}
|
}
|
||||||
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
||||||
.cms-contact {
|
.cms-contact {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-family: var(--font-ui);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .5rem;
|
gap: .4rem;
|
||||||
margin: 1.25rem 0;
|
margin: 1rem 0;
|
||||||
background: var(--color-surface-warm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
}
|
||||||
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
||||||
|
|
||||||
/* ── Abgabetiere — group cards ── */
|
/* ── Abgabetiere ── */
|
||||||
.cms-abgabe { margin: 1.25rem 0; }
|
.cms-abgabe { margin: 1rem 0; }
|
||||||
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
||||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||||
|
.cms-animal-grid {
|
||||||
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
.cms-group-card {
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.cms-animal-card {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
|
||||||
}
|
|
||||||
.cms-group-header {
|
|
||||||
padding: 1rem 1.25rem .75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem .75rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface-warm);
|
|
||||||
}
|
|
||||||
.cms-group-name {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.cms-group-meta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
}
|
||||||
|
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
|
||||||
|
.cms-animal-info { padding: .875rem; }
|
||||||
|
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
|
||||||
|
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
|
||||||
|
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
|
||||||
|
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
|
||||||
|
|
||||||
/* availability badge */
|
@media (max-width: 480px) {
|
||||||
.cms-badge {
|
h1.cms-heading { font-size: 1.4rem; }
|
||||||
display: inline-block;
|
.cms-animal-grid { grid-template-columns: 1fr; }
|
||||||
font-size: .75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
padding: .2rem .55rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.cms-badge--free {
|
|
||||||
background: var(--color-badge-free-bg);
|
|
||||||
color: var(--color-badge-free);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* group photo strip */
|
|
||||||
.cms-group-gallery {
|
|
||||||
display: flex;
|
|
||||||
gap: .375rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding: .75rem 1.25rem;
|
|
||||||
background: #f5ede4;
|
|
||||||
}
|
|
||||||
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
|
||||||
.cms-group-photo {
|
|
||||||
width: 140px;
|
|
||||||
height: 140px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* animal detail list */
|
|
||||||
.cms-animal-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: .75rem 1.25rem 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: .875rem;
|
|
||||||
}
|
|
||||||
.cms-animal-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: .15rem;
|
|
||||||
padding-bottom: .875rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
|
||||||
.cms-animal-name {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.cms-animal-detail {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
.cms-animal-note {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: .925rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-top: .1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.site-header { padding: 0 .75rem; min-height: 50px; }
|
|
||||||
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
|
||||||
h1.cms-heading { font-size: 1.5rem; }
|
|
||||||
.site-main { margin-top: 1.5rem; }
|
|
||||||
.cms-group-photo { width: 110px; height: 110px; }
|
|
||||||
}
|
|
||||||
@media (min-width: 700px) {
|
|
||||||
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
|
||||||
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
{
|
{
|
||||||
g.Id,
|
g.Id,
|
||||||
g.Name,
|
g.Name,
|
||||||
g.Gender,
|
|
||||||
g.DateOfBirth,
|
|
||||||
g.CharacterNote,
|
|
||||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||||
@@ -112,9 +109,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
arr.Add(new JsonObject
|
arr.Add(new JsonObject
|
||||||
{
|
{
|
||||||
["name"] = a.Name,
|
["name"] = a.Name,
|
||||||
["gender"] = a.Gender.ToString(),
|
|
||||||
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
|
||||||
["characterNote"] = a.CharacterNote,
|
|
||||||
["farbschlag"] = a.Farbschlag,
|
["farbschlag"] = a.Farbschlag,
|
||||||
["group"] = a.Group,
|
["group"] = a.Group,
|
||||||
["photos"] = photoArr,
|
["photos"] = photoArr,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
int ParentFksBackfilled = 0);
|
int ParentFksBackfilled = 0, int WithoutDate = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
|
|||||||
@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
int littersCreated = 0, littersExisting = 0;
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
|
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||||
|
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||||
|
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||||
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
{
|
{
|
||||||
var date = ParseDate(sl.Date);
|
var date = ParseDate(sl.Date);
|
||||||
|
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||||
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
littersCreated++;
|
littersCreated++;
|
||||||
if (execute && date is DateOnly d)
|
if (execute)
|
||||||
{
|
{
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Name = name,
|
||||||
Date = d,
|
Date = date.Value,
|
||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8 && date is not null)
|
if (samples.Count < 8)
|
||||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
@@ -454,6 +460,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (littersWithoutDate > 0)
|
||||||
|
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
if (parentLinksAdded > 0)
|
if (parentLinksAdded > 0)
|
||||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||||
@@ -468,7 +476,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
Executed: execute,
|
Executed: execute,
|
||||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
|
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
|
||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddImpressumDatenschutzPages : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.InsertData(
|
|
||||||
table: "Pages",
|
|
||||||
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
|
||||||
values: new object[,]
|
|
||||||
{
|
|
||||||
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
|
|
||||||
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
|
||||||
table: "Blocks",
|
|
||||||
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
|
||||||
values: new object[,]
|
|
||||||
{
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Pages",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Pages",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -103,38 +103,6 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Order = 1,
|
Order = 1,
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
||||||
Type = "AbgabetiereList"
|
Type = "AbgabetiereList"
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000007"),
|
|
||||||
Data = "{\"text\":\"Impressum\",\"level\":1}",
|
|
||||||
Order = 0,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
|
||||||
Type = "Heading"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000070"),
|
|
||||||
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
|
||||||
Order = 1,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
|
||||||
Type = "RichText"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000008"),
|
|
||||||
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
|
|
||||||
Order = 0,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
|
||||||
Type = "Heading"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000080"),
|
|
||||||
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
|
||||||
Order = 1,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
|
||||||
Type = "RichText"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1021,20 +989,6 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Slug = "kontakt",
|
Slug = "kontakt",
|
||||||
Status = "Published",
|
Status = "Published",
|
||||||
Title = "Kontakt"
|
Title = "Kontakt"
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720001-0000-0000-0000-000000000007"),
|
|
||||||
Slug = "impressum",
|
|
||||||
Status = "Published",
|
|
||||||
Title = "Impressum"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720001-0000-0000-0000-000000000008"),
|
|
||||||
Slug = "datenschutz",
|
|
||||||
Status = "Published",
|
|
||||||
Title = "Datenschutz"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.SaleAd
|
namespace GerbilManagerWebAPI.SaleAd
|
||||||
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
|
|||||||
public static class SaleAdPromptBuilder
|
public static class SaleAdPromptBuilder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Two few-shot examples derived from the documented listing template.
|
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
|
||||||
/// NOTE: the research file documents the format and one real tagline;
|
/// These are real listings in the breeder's own voice — the strongest grounding source.
|
||||||
/// these examples are synthesized to that template. Swap in verbatim
|
|
||||||
/// listings from kleine-chaoten.jimdofree.com when available.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string ExampleListing1 = """
|
private const string ExampleListing1 = """
|
||||||
Status: FREI
|
Status: LOCKER RESERVIERT LEONIE
|
||||||
|
|
||||||
„Großer und kleiner Bruder Dynamik“ – Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
|
„Großer und kleiner Bruder Dynamik” – Männliches Rennmaus Duo sucht ein liebevolles Zuhause
|
||||||
|
|
||||||
Balu – CP-Agouti, geboren am 12.03.2024
|
Hier steht ein außergewöhnlich harmonisches Rennmaus-Duo zur Abgabe frei, das nicht nur durch seinen Charakter, sondern auch durch seine besondere Geschichte überzeugt. Die zwei verhalten sich als ob sie Brüder wären und sind unzertrennlich. Das Duo kann ab sofort ausziehen.
|
||||||
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
|
|
||||||
|
|
||||||
Benny – Schwarz Schecke, geboren am 12.03.2024
|
Der Ältere von beiden trägt die Farbe CP-Agouti und ist am 11.11.2025 geboren. Er ist ein neugieriger, aktiver und sozialer kleiner Kerl. Er liebt es, seine Umgebung zu erkunden, ist gerne in Bewegung und bringt dabei eine angenehme Ausgeglichenheit mit. Er wirkt sicher, aufmerksam und zeigt ein rundum unkompliziertes Wesen.
|
||||||
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest — getrennt werden die beiden deshalb nicht.
|
|
||||||
|
|
||||||
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
Sein, etwas jünger, Partner trägt die Farbe Schwarz Schecke und ist am 26.02.2026 geboren. Statt des in diesem Alter oft typischen Kräftemessens zeigt er ein erstaunlich sanftes, ruhiges und sehr soziales Verhalten. Man merkt ihm an, wie feinfühlig und verträglich er ist.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string ExampleListing2 = """
|
private const string ExampleListing2 = """
|
||||||
Status: LOCKER RESERVIERT Anna
|
Status: FREI
|
||||||
|
|
||||||
„Zwei Schwestern, ein Herz und ganz viel Neugier“ – Weibliches Duo sucht seine Menschen
|
„Unzertrennliches Mutter-Tochter Duo sucht gemeinsam neue Körnergeber”
|
||||||
|
|
||||||
Frieda – Gold, geboren am 28.06.2024
|
Die Mama ist am 13.08.2025 geboren und trägt die Farbe Kohlfuchs. Sie ist eine eher ruhige und vorsichtige Rennmaus, die neuen Situationen zunächst aufmerksam begegnet. Gibt man ihr etwas Zeit, zeigt sich schnell ihre neugierige Seite und sie kommt gerne schauen, was im Gehege passiert. Sie ist ein echtes Energiebündel, liebt es zu buddeln, zu rennen und alles an Beschäftigungsmaterial – besonders Klorollen – kreativ umzugestalten. Charakterlich zeigt sie sich sehr sozial und friedlich; besonders hervorzuheben ist ihre fürsorgliche Art als Mutter.
|
||||||
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
|
|
||||||
|
|
||||||
Fine – Agouti, geboren am 28.06.2024
|
Die Tochter ist am 26.02.2026 geboren und trägt die Farbe Agouti-Schecke. Sie ist ihrer Mama sehr ähnlich, dabei aber schon ein kleines bisschen mutiger. Auch sie beobachtet erst gerne in Ruhe, bevor die Neugier siegt. Sie ist sehr anhänglich und orientiert sich stark an ihrer Mutter.
|
||||||
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
|
|
||||||
|
|
||||||
Die Schwestern ziehen selbstverständlich nur zusammen um.
|
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|||||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
||||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
||||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
||||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
||||||
|
|||||||
129
gerbil-manager-web/e2e/abgabe.spec.ts
Normal file
129
gerbil-manager-web/e2e/abgabe.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
|
||||||
|
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
|
||||||
|
*
|
||||||
|
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const ta = de.pages.abgabe
|
||||||
|
const tw = de.pages.vertraege.wizard
|
||||||
|
|
||||||
|
test.describe('Abgabe', () => {
|
||||||
|
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText(ta.grouping.title)).toBeVisible()
|
||||||
|
|
||||||
|
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
// Inserat-Vorschau (listing-preview) wird gerendert
|
||||||
|
await expect(page.locator('.listing-preview').first()).toBeVisible()
|
||||||
|
// Status-Dropdown standard: FREI
|
||||||
|
await expect(
|
||||||
|
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
|
||||||
|
).toHaveValue('free')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: ta.ai.improve }).first().click()
|
||||||
|
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
|
||||||
|
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
mockDb!.saleAdConfigured = false
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: ta.ai.improve }).first().click()
|
||||||
|
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
|
||||||
|
await page.getByRole('button', { name: ta.export.finish }).first().click()
|
||||||
|
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
|
||||||
|
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
|
||||||
|
// Schritt 1: Abnehmer auswählen
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Vertrag-Wizard', () => {
|
||||||
|
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
|
||||||
|
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
|
||||||
|
|
||||||
|
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
|
||||||
|
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
|
||||||
|
await expect(
|
||||||
|
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
|
||||||
|
).toBeChecked()
|
||||||
|
await expect(
|
||||||
|
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
|
||||||
|
).toBeChecked()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 3: Preis & Datum
|
||||||
|
await page.getByLabel(tw.priceLabel).fill('50,00')
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 4: Zusammenfassung + Erzeugen
|
||||||
|
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
|
||||||
|
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
|
||||||
|
await expect(page.getByText('Balu Abgabe')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.generate }).click()
|
||||||
|
|
||||||
|
// Erfolg: Vertrag erstellt, Download-Link vorhanden
|
||||||
|
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu')
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
|
||||||
|
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu')
|
||||||
|
// Schritt 1: Abnehmer wählen
|
||||||
|
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Schritt 2: Tiere bestätigen
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Schritt 3: leeres Preisfeld -> Fehler
|
||||||
|
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||||
*/
|
*/
|
||||||
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const ta = de.pages.anfragen
|
const ta = de.pages.anfragen
|
||||||
const td = ta.detail
|
const td = ta.detail
|
||||||
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
|
|||||||
// Status-Badge auf der Karte
|
// Status-Badge auf der Karte
|
||||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Filter: nur Beantwortet
|
// Filter: nur Beantwortet
|
||||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||||
await expect(cards).toHaveCount(1)
|
await expect(cards).toHaveCount(1)
|
||||||
|
|||||||
@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
|
|||||||
|
|
||||||
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
||||||
await gotoSection(page, de.nav.contacts)
|
await gotoSection(page, de.nav.contacts)
|
||||||
|
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
|
||||||
|
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
|
||||||
|
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
|
||||||
|
// wartet aber nicht auf den vollständigen Seitenaufbau).
|
||||||
|
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
|
||||||
await page.getByRole('link', { name: tk.newButton }).click()
|
await page.getByRole('link', { name: tk.newButton }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
|
||||||
const name = uniqueName('Kontakt')
|
const name = uniqueName('Kontakt')
|
||||||
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
||||||
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
@@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
|
|||||||
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
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()
|
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||||
|
|
||||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
|
|||||||
@@ -17,5 +17,10 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
|
|||||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||||
await page.goto('/abgabe')
|
await page.goto('/abgabe')
|
||||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||||
|
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||||
|
const kruemelSection = page.locator('.group-composer__animal').filter({
|
||||||
|
has: page.locator('strong', { hasText: 'Krümel' }),
|
||||||
|
})
|
||||||
|
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
||||||
})
|
})
|
||||||
|
|||||||
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||||
|
*
|
||||||
|
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||||
|
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, 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) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Toggle öffnet und schließt den Filter-Drawer', 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()
|
||||||
|
|
||||||
|
// Schließen → wieder verborgen.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,6 +39,17 @@ export function skipUnlessMock() {
|
|||||||
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
|
||||||
|
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
|
||||||
|
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
|
||||||
|
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
|
||||||
|
* wenn wir nur testen, ob die Seite lädt.
|
||||||
|
*/
|
||||||
|
export function skipUnlessLive() {
|
||||||
|
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
||||||
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||||
@@ -63,6 +74,18 @@ export function acceptNextDialog(page: Page) {
|
|||||||
page.once('dialog', (d) => void d.accept())
|
page.once('dialog', (d) => void d.accept())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
|
||||||
|
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
|
||||||
|
* kein Klick nötig — Controls sind direkt sichtbar.
|
||||||
|
*/
|
||||||
|
export async function openFilterPanel(page: Page) {
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
if (await toggle.isVisible()) {
|
||||||
|
await toggle.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||||
export const uniqueName = (prefix: string) =>
|
export const uniqueName = (prefix: string) =>
|
||||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
|||||||
54
gerbil-manager-web/e2e/genotype-table.spec.ts
Normal file
54
gerbil-manager-web/e2e/genotype-table.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-2: Genotyp-Tabellen-Overflow — die Tabelle scrollt horizontal,
|
||||||
|
* die Seite selbst bleibt NICHT breiter als der Viewport (kein horizontaler
|
||||||
|
* Page-Overflow auf 390px).
|
||||||
|
*/
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const tl = de.pages.litters
|
||||||
|
|
||||||
|
test('Phone: Genotyp-Detail-Tabelle scrollt im eigenen Wrapper, kein Page-Overflow', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
// Wurf w-kruemel hat beide Eltern mit Genotypen → BreedingResultView rendert.
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Genotyp-Detail-Tabelle ausklappen.
|
||||||
|
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
|
||||||
|
|
||||||
|
// Scroll-Wrapper und Tabelle müssen vorhanden und sichtbar sein.
|
||||||
|
const wrapper = page.locator('.genotype-table-scroll').first()
|
||||||
|
await expect(wrapper).toBeVisible()
|
||||||
|
await expect(wrapper.locator('.genotype-table')).toBeVisible()
|
||||||
|
|
||||||
|
// Wrapper ist selbst scrollbar (Tabelle breiter als der sichtbare Bereich).
|
||||||
|
const wrapperScrollable = await wrapper.evaluate(
|
||||||
|
(el) => el.scrollWidth > el.clientWidth,
|
||||||
|
)
|
||||||
|
expect(wrapperScrollable).toBe(true)
|
||||||
|
|
||||||
|
// Die Seite selbst darf NICHT breiter als der Viewport sein.
|
||||||
|
const pageOverflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
|
)
|
||||||
|
expect(pageOverflow).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Desktop: Genotyp-Detail-Tabelle zeigt Wrapper auch auf breitem Viewport', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'desktop') return
|
||||||
|
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.genotype-table-scroll').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.genotype-table').first()).toBeVisible()
|
||||||
|
})
|
||||||
57
gerbil-manager-web/e2e/live.spec.ts
Normal file
57
gerbil-manager-web/e2e/live.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* QA-2: Live-Modus-Smoke — läuft NUR gegen das echte Backend (E2E_BASE_URL).
|
||||||
|
*
|
||||||
|
* Lektion (aus dem Such-Bug): Mock-Tests spiegeln nur den Frontend-Code.
|
||||||
|
* Ein Backend-Bug (falscher Filter, leere Antwort, fehlende Migration) bleibt
|
||||||
|
* unsichtbar, wenn Live-Tests nur "Seite lädt" prüfen. Diese Tests assertieren
|
||||||
|
* ERGEBNISSE: Liste nicht leer, Suche liefert Treffer, Detail zeigt Stammdaten
|
||||||
|
* und Eltern-Links.
|
||||||
|
*
|
||||||
|
* Voraussetzung für den Eltern-Link-Test: Re-Import #2 durchgeführt
|
||||||
|
* (322 Tiere, 882 Würfe live; mindestens ein Wurf hat beide Eltern).
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessLive, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
const tl = de.pages.litters
|
||||||
|
|
||||||
|
test('Live: Tiere-Liste enthält nach Re-Import mindestens 10 Einträge', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
// Leere Liste = Backend-Bug (falscher Filter, fehlende DB-Migration o. ä.)
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live: Namenssuche gibt Treffer zurück — nicht nur HTTP 200', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
// Kurzer Prefix: bei 322 Tieren mindestens 1 Treffer. Kein Treffer = Gridify-Bug.
|
||||||
|
await page.getByPlaceholder(t.searchPlaceholder).first().fill('a')
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
|
||||||
|
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
|
||||||
|
await gotoSection(page, de.nav.litters)
|
||||||
|
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Mindestens 1 Wurf in der Liste; ersten öffnen.
|
||||||
|
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
|
||||||
|
await expect(firstLink).toBeVisible()
|
||||||
|
await firstLink.click()
|
||||||
|
|
||||||
|
// Detailseite: Elterntier-Abschnitt ist sichtbar
|
||||||
|
await expect(page.getByText(tl.detail.parents)).toBeVisible()
|
||||||
|
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
|
||||||
|
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
})
|
||||||
@@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
||||||
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
||||||
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
||||||
|
contracts: collection(db.contracts as unknown as Row[], 'contract'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (route: Route) => {
|
const handler = async (route: Route) => {
|
||||||
@@ -205,6 +206,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route
|
||||||
|
if (path === '/gerbils/sale-ad' && method === 'POST') {
|
||||||
|
if (!db.saleAdConfigured) {
|
||||||
|
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||||
|
}
|
||||||
|
return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" – Generierter Inserat-Text für den Mock.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient)
|
||||||
|
const cm = path.match(/^\/contracts\/([^/]+)\/file$/)
|
||||||
|
if (cm && method === 'GET') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` },
|
||||||
|
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||||
// generischen /gerbils/:id-Route abfangen.
|
// generischen /gerbils/:id-Route abfangen.
|
||||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||||
@@ -313,6 +333,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben
|
||||||
|
if (path === '/contracts' && method === 'POST') {
|
||||||
|
const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null }
|
||||||
|
const id = newId('contract')
|
||||||
|
const contract = {
|
||||||
|
id,
|
||||||
|
contactId: body.contactId,
|
||||||
|
gerbilIds: body.gerbilIds ?? [],
|
||||||
|
price: body.price,
|
||||||
|
handoverDate: body.handoverDate,
|
||||||
|
contractDate: body.contractDate ?? body.handoverDate,
|
||||||
|
fileName: `vertrag-${id}.docx`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
url: `/contracts/${id}/file`,
|
||||||
|
}
|
||||||
|
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
|
||||||
|
// Tiere auf GivenAway setzen (wie das echte Backend)
|
||||||
|
for (const gid of body.gerbilIds ?? []) {
|
||||||
|
const g = db.gerbils.find((x) => x.id === gid)
|
||||||
|
if (g) {
|
||||||
|
g.status = 'GivenAway'
|
||||||
|
g.receiverContactId = body.contactId
|
||||||
|
g.goHomeDate = body.handoverDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(route, 201, contract)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ export interface MockPage {
|
|||||||
blocks: MockBlock[]
|
blocks: MockBlock[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ABGABE: Abgabevertrag (mirrors SaleContract in src/api/contracts.ts). */
|
||||||
|
export interface MockContract {
|
||||||
|
id: string
|
||||||
|
contactId: string
|
||||||
|
price: number
|
||||||
|
handoverDate: string
|
||||||
|
contractDate: string
|
||||||
|
fileName: string
|
||||||
|
createdAt: string
|
||||||
|
gerbilIds: string[]
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
gerbils: Gerbil[]
|
gerbils: Gerbil[]
|
||||||
litters: Litter[]
|
litters: Litter[]
|
||||||
@@ -58,6 +71,9 @@ export interface MockDb {
|
|||||||
requests: InboxRequest[]
|
requests: InboxRequest[]
|
||||||
aiConfigured: boolean
|
aiConfigured: boolean
|
||||||
mailConfigured: boolean
|
mailConfigured: boolean
|
||||||
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
|
contracts: MockContract[]
|
||||||
|
saleAdConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -119,6 +135,20 @@ export function seedDb(): MockDb {
|
|||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||||
|
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||||
|
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
||||||
|
{
|
||||||
|
...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'),
|
||||||
|
status: 'ForSale' as const,
|
||||||
|
enclosureId: 'enc-leer',
|
||||||
|
notes: 'ruhig, nimmt Leckerlis aus der Hand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...gerbil('sale-benny', 'Benny Abgabe', 'male', '2024-03-12', null, 'cv-schwarz-schecke'),
|
||||||
|
status: 'ForSale' as const,
|
||||||
|
enclosureId: 'enc-leer',
|
||||||
|
notes: 'neugieriger Entdecker',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const litters: Litter[] = [
|
const litters: Litter[] = [
|
||||||
@@ -258,5 +288,7 @@ export function seedDb(): MockDb {
|
|||||||
requests,
|
requests,
|
||||||
aiConfigured: true,
|
aiConfigured: true,
|
||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
|
contracts: [],
|
||||||
|
saleAdConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
const tabs = de.pages.tierTabs
|
const tabs = de.pages.tierTabs
|
||||||
@@ -23,6 +23,8 @@ 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: /Krümel/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).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).
|
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||||
await page
|
await page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showGenotypes && (
|
{showGenotypes && (
|
||||||
|
<div className="genotype-table-scroll">
|
||||||
<table className="genotype-table">
|
<table className="genotype-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -87,6 +88,7 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import './filterPanel.css'
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
/** Always visible on mobile (typically the search text input). Optional. */
|
||||||
|
searchField?: ReactNode
|
||||||
|
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
|
||||||
|
children: ReactNode
|
||||||
|
/** Number of currently active (non-default) filter values. Shown as badge. */
|
||||||
|
activeCount: number
|
||||||
|
/** Called when the reset button is clicked. */
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
|
||||||
|
* Render inside the existing `.filters` div (or replace it entirely).
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): renders all children inline, identical to today.
|
||||||
|
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
|
||||||
|
* the rest of the controls in a column drawer + a reset button.
|
||||||
|
*/
|
||||||
|
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const t = de.filterPanel
|
||||||
|
|
||||||
|
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
|
||||||
|
{searchField}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost filter-panel__toggle"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div className="filter-panel__drawer">
|
||||||
|
{children}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
|
||||||
|
{t.resetButton}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
|
||||||
|
* children participate directly in the parent .filters flex row.
|
||||||
|
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
|
||||||
|
* width drawer (flex column) with the remaining filters + reset button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Mobile default ── */
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel--open .filter-panel__drawer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ── */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
/* Let children participate directly in the parent flex row. */
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset button sits in the flex row on desktop when active filters exist. */
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile reset button ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,10 +179,11 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
|
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
|
||||||
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
|
||||||
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
|
||||||
expect(CATALOG_SIZE).toBe(61)
|
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
|
||||||
|
expect(CATALOG_SIZE).toBe(66)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||||
@@ -414,8 +415,12 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
|||||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('A- + cchm/ch -> CP-<base colour>', () => {
|
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
|
||||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||||
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
@@ -471,10 +476,50 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
|||||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
|
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
|
||||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||||
|
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||||
|
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||||
|
|
||||||
|
it('all new -Hell het entries exist in catalog', () => {
|
||||||
|
for (const n of [
|
||||||
|
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
|
||||||
|
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
|
||||||
|
]) {
|
||||||
|
expect(has(n)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
|
||||||
|
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||||
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
|
||||||
|
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||||
|
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||||
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
|
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||||
|
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||||||
|
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||||
|
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,27 +107,29 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||||
|
|
||||||
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||||
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||||
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
|
||||||
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
|
||||||
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
|
||||||
// in her live data (god: extract animals.json) so the re-import name-matches and
|
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
|
||||||
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
|
||||||
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
|
|
||||||
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
|
|
||||||
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
|
|
||||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||||
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||||
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||||
|
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||||
|
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||||
@@ -207,12 +209,14 @@ function baseColourFor(g: Genotype): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||||
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
||||||
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
||||||
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
||||||
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
||||||
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
|
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
|
||||||
|
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
|
||||||
|
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
|
||||||
*/
|
*/
|
||||||
function colourpointName(g: Genotype): string | null {
|
function colourpointName(g: Genotype): string | null {
|
||||||
const c = resolvedPair(g, 'C')
|
const c = resolvedPair(g, 'C')
|
||||||
@@ -227,9 +231,9 @@ function colourpointName(g: Genotype): string | null {
|
|||||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||||
return bothCchm ? 'Marder' : 'Siam'
|
return bothCchm ? 'Marder' : 'Siam'
|
||||||
}
|
}
|
||||||
// A- colourpoint -> CP-<base colour>, base as if C were full.
|
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
return base ? `CP-${base}` : null
|
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||||
|
|||||||
@@ -340,42 +340,67 @@
|
|||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 54
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
"sortOrder": 54,
|
"sortOrder": 55,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 56
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
"sortOrder": 55,
|
"sortOrder": 57,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 58
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
"sortOrder": 56,
|
"sortOrder": 59,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
"sortOrder": 57
|
"sortOrder": 61
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"name": "CP-Fuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||||
"sortOrder": 58
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"name": "CP-Blaufuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
"sortOrder": 59
|
"sortOrder": 63
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"name": "CP-Orangeschimmel",
|
||||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
"sortOrder": 60
|
"sortOrder": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 65
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ a {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
max-width: 60rem;
|
max-width: 60rem;
|
||||||
width: 100%;
|
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. */
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab-Leiste unten */
|
/* Tab-Leiste unten */
|
||||||
@@ -559,8 +563,14 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
|
||||||
|
instead of overflowing the page. */
|
||||||
|
.genotype-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.genotype-table {
|
.genotype-table {
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -571,6 +581,7 @@ textarea {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genotype-table code {
|
.genotype-table code {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../api/requests'
|
} from '../api/requests'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDateTime } from '../format/labels'
|
import { formatDateTime } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './anfragen.css'
|
import './anfragen.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
|
|||||||
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||||
|
|
||||||
{/* Status-Filter */}
|
{/* Status-Filter */}
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={status !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setStatus(''); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.detail.statusLabel}</span>
|
<span>{t.detail.statusLabel}</span>
|
||||||
<select
|
<select
|
||||||
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{requests.error && (
|
{requests.error && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './gerbils.css'
|
import './gerbils.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
const items = gerbils.data?.items ?? []
|
const items = gerbils.data?.items ?? []
|
||||||
|
|
||||||
|
// UX-MOBILE-1: count non-default filter values for the badge.
|
||||||
|
const activeFilterCount =
|
||||||
|
(status !== 'Active' ? 1 : 0) +
|
||||||
|
(gender !== '' ? 1 : 0) +
|
||||||
|
(colorVarietyId !== '' ? 1 : 0) +
|
||||||
|
(originBreeder !== '' ? 1 : 0) +
|
||||||
|
(showExternal ? 1 : 0)
|
||||||
|
|
||||||
// Multi-select bulk "Zur Abgabe stellen".
|
// Multi-select bulk "Zur Abgabe stellen".
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const toggleSelect = (id: string) =>
|
const toggleSelect = (id: string) =>
|
||||||
@@ -118,7 +127,8 @@ export default function GerbilsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
searchField={
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -127,6 +137,10 @@ export default function GerbilsPage() {
|
|||||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||||
aria-label={t.fields.name}
|
aria-label={t.fields.name}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
activeCount={activeFilterCount}
|
||||||
|
onReset={resetFilters}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filters.status}</span>
|
<span>{t.filters.status}</span>
|
||||||
<select
|
<select
|
||||||
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
|
|||||||
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn" onClick={resetFilters}>
|
</FilterPanel>
|
||||||
{t.filters.reset}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{gerbils.error && (
|
{gerbils.error && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import type { Litter } from '../api/types'
|
import type { Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate } from '../format/labels'
|
import { formatDate } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
|
|||||||
|
|
||||||
{tab === 'litters' && (
|
{tab === 'litters' && (
|
||||||
<>
|
<>
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={year !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filterYear}</span>
|
<span>{t.filterYear}</span>
|
||||||
<select
|
<select
|
||||||
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
|
|||||||
<option value="dateAsc">{t.sort.dateAsc}</option>
|
<option value="dateAsc">{t.sort.dateAsc}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{litters.error && (
|
{litters.error && (
|
||||||
|
|||||||
@@ -807,6 +807,12 @@ export const de = {
|
|||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
|
filterPanel: {
|
||||||
|
toggleButton: 'Filter',
|
||||||
|
resetButton: 'Filter zurücksetzen',
|
||||||
|
closeButton: 'Schließen',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
- Nach Zusammenführung (eindeutige Tiere): **621**
|
- Nach Zusammenführung (eindeutige Tiere): **621**
|
||||||
- davon mit Geburtsdatum: 326
|
- davon mit Geburtsdatum: 326
|
||||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||||
- Konflikte zur Klärung: **8**
|
- Konflikte zur Klärung: **5**
|
||||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
||||||
- Fotos zugeordnet: **137**
|
- Fotos zugeordnet: **137**
|
||||||
- Würfe aus der Wurfchronik: **752**
|
- Würfe aus der Wurfchronik: **752**
|
||||||
@@ -25,12 +25,9 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|
|
||||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
|
||||||
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
||||||
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
|
|
||||||
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
||||||
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||||
| Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
|
||||||
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
||||||
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
|
|
||||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/+` | 7 | ? |
|
| `/+` | 6 | ? |
|
||||||
| `-g` | 2 | ? |
|
| `-g` | 2 | ? |
|
||||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||||
| `Cc[]` | 1 | ? |
|
| `Cc[]` | 1 | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user