Compare commits
30 Commits
36454f3747
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e7ec5ab61 | |||
| 12604fba7c | |||
| 311c5461fd | |||
| 3634d6ef9a | |||
| aa345aa796 | |||
| 0317c5d454 | |||
| ea8c3e9966 | |||
| 4d3383a4f1 | |||
| 19a0a6c79b | |||
| b9033238c9 | |||
| 594923e10f | |||
| aa20d6f809 | |||
| afd321ec9f | |||
| ae825ae80a | |||
| e055061d85 | |||
| df13136955 | |||
| 4aca1d528b | |||
| 4e02133d95 | |||
| f3efd4aa2b | |||
| 461d7feb2d | |||
| 7536aba474 | |||
| 972570b142 | |||
| 029ffb9895 | |||
| 6decd59ce4 | |||
| f015274d28 | |||
| fd48f7fc09 | |||
| 105e63b9c4 | |||
| 0334fce275 | |||
| aaaf5b6fb7 | |||
| c20e6b5426 |
@@ -104,7 +104,7 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
|
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
|
||||||
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
|
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
|
||||||
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
|
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
|
||||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
|
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
|
||||||
var g = root.GetProperty("gerbils")[0];
|
var g = root.GetProperty("gerbils")[0];
|
||||||
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
|
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
|
||||||
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
|
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GerbilManagerWebAPI.Import;
|
using GerbilManagerWebAPI.Import;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
@@ -191,6 +192,119 @@ namespace GerbilManager.Tests
|
|||||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
|
||||||
|
{
|
||||||
|
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
|
||||||
|
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
|
||||||
|
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[
|
||||||
|
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||||
|
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints
|
||||||
|
|
||||||
|
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||||
|
Assert.NotNull(c.LitterId);
|
||||||
|
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||||
|
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
|
||||||
|
Assert.Equal(0, report.Litters.ParentFksDropped);
|
||||||
|
Assert.Equal(0, report.Litters.DerivedSkipped);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||||
|
{
|
||||||
|
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
|
||||||
|
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||||
|
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[
|
||||||
|
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||||
|
{"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw
|
||||||
|
|
||||||
|
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
|
||||||
|
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
|
||||||
|
Assert.NotNull(c2.LitterId); // still linked (via mother)
|
||||||
|
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
|
||||||
|
Assert.Null(litter.FatherId); // quarantined father -> null FK
|
||||||
|
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decision_resolved_animal_loads_and_is_counted()
|
||||||
|
{
|
||||||
|
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
|
||||||
|
// un-quarantines an animal; the loader must then LOAD it and surface the count.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
|
||||||
|
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
|
||||||
|
"conflict":false,"resolvedByDecision":true}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
|
||||||
|
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
|
||||||
|
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -256,8 +256,6 @@ public class ApplicationContext : DbContext
|
|||||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
||||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
||||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
|
||||||
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
|
|
||||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
||||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
||||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||||
@@ -267,44 +265,34 @@ public class ApplicationContext : DbContext
|
|||||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
||||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
||||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
||||||
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
|
|
||||||
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
|
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||||
("Polarfuchs-Hell CP", "AA cchmcchm DD ee gg PP spsp rere"),
|
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
||||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
||||||
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
|
||||||
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
|
||||||
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
|
|
||||||
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
|
||||||
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
|
||||||
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
|
||||||
("Agouti-Hell dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
||||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
||||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
||||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
||||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
||||||
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
||||||
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
|
||||||
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
|
||||||
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
|
|
||||||
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
|
|
||||||
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
|
||||||
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
|
||||||
("Polarfuchs CP", "AA cchmcchm DD ee gg PP spsp rere"),
|
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
||||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
||||||
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
|
||||||
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
|
|
||||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
||||||
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||||
|
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
||||||
|
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
||||||
|
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||||
|
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||||
|
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||||
|
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||||
|
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
||||||
|
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
||||||
|
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
||||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
|
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
|
||||||
public bool? Deaf { get; set; }
|
public bool? Deaf { get; set; }
|
||||||
public List<string> Tags { get; set; } = new();
|
public List<string> Tags { get; set; } = new();
|
||||||
|
|
||||||
|
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
|
||||||
|
// this animal (its genotype/farbschlag are then authoritative). For reporting.
|
||||||
|
public bool ResolvedByDecision { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SourceGenotype
|
public sealed class SourceGenotype
|
||||||
@@ -81,7 +85,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, int DerivedFromChart = 0);
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
@@ -91,7 +96,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int FarbschlagUnmatched,
|
int FarbschlagUnmatched,
|
||||||
int AlreadyImported,
|
int AlreadyImported,
|
||||||
QuarantineSummary Quarantined,
|
QuarantineSummary Quarantined,
|
||||||
int ParentLinksFromChart = 0);
|
int ParentLinksFromChart = 0,
|
||||||
|
int ConflictsResolvedByDecision = 0);
|
||||||
|
|
||||||
public sealed record QuarantineSummary(
|
public sealed record QuarantineSummary(
|
||||||
int Conflicts,
|
int Conflicts,
|
||||||
|
|||||||
@@ -192,18 +192,52 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
parentLinksAdded++;
|
parentLinksAdded++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters.
|
// FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a
|
||||||
// Used by the residency rule (b) below; augmented with existing DB litters under execute.
|
// gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute
|
||||||
|
// the persisted set (existing DB rows + this run's loadable animals) and drop any parent
|
||||||
|
// FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its
|
||||||
|
// offspring then load with LitterId=null — still better than 'unbekannt' won't regress).
|
||||||
|
// This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault.
|
||||||
|
var persisted = new HashSet<Guid>(existingRows.Select(r => r.Id));
|
||||||
|
foreach (var p in plan) persisted.Add(p.Gid);
|
||||||
|
int litterParentFksDropped = 0, derivedLittersSkipped = 0;
|
||||||
|
foreach (var key in synthLitters.Keys.ToList())
|
||||||
|
{
|
||||||
|
var sl = synthLitters[key];
|
||||||
|
var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null;
|
||||||
|
var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null;
|
||||||
|
if (sl.Father is not null && f is null) litterParentFksDropped++;
|
||||||
|
if (sl.Mother is not null && m is null) litterParentFksDropped++;
|
||||||
|
if (f is null && m is null)
|
||||||
|
{
|
||||||
|
derivedLittersSkipped++; derivedLitters--;
|
||||||
|
foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||||
|
synthLitterForGid.Remove(gid);
|
||||||
|
synthLitters.Remove(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
synthLitters[key] = sl with { Father = f, Mother = m };
|
||||||
|
}
|
||||||
|
parentLinksAdded = synthLitterForGid.Count;
|
||||||
|
|
||||||
|
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters,
|
||||||
|
// each FK guarded by the persisted set. Used by residency rule (b) below; augmented with
|
||||||
|
// existing DB litters under execute.
|
||||||
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
|
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
|
||||||
foreach (var sl in synthLitters.Values)
|
foreach (var sl in synthLitters.Values)
|
||||||
litterParents[sl.Id] = (sl.Father, sl.Mother);
|
litterParents[sl.Id] = (sl.Father, sl.Mother);
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
if (litterIdMap.TryGetValue(sl.Id, out var lid))
|
if (litterIdMap.TryGetValue(sl.Id, out var lid))
|
||||||
litterParents[lid] = (
|
{
|
||||||
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) ? fid : (Guid?)null,
|
Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null;
|
||||||
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) ? mid : (Guid?)null);
|
Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null;
|
||||||
|
litterParents[lid] = (f, m);
|
||||||
|
}
|
||||||
|
|
||||||
// PASS 2: write (litters synthesized first so offspring FK resolves), then animals + photos.
|
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
||||||
|
// before the animals — the offspring AND the parent gerbils are created in the loop
|
||||||
|
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
||||||
|
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||||
@@ -237,7 +271,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
// NOTE: no SaveChanges here — staged with the gerbils below.
|
||||||
}
|
}
|
||||||
|
|
||||||
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
||||||
@@ -356,9 +390,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
|
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
|
||||||
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
|
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
|
||||||
if (litter is null) continue;
|
if (litter is null) continue;
|
||||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId))
|
// guard: only link parents that are actually persisted (avoid an orphan FK).
|
||||||
|
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
|
||||||
litter.FatherId = fId;
|
litter.FatherId = fId;
|
||||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId))
|
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
|
||||||
litter.MotherId = mId;
|
litter.MotherId = mId;
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
@@ -367,16 +402,20 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
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).");
|
||||||
|
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
|
if (conflictsResolvedByDecision > 0)
|
||||||
|
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
Executed: execute,
|
Executed: execute,
|
||||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters),
|
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
|
||||||
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),
|
||||||
parentLinksAdded),
|
parentLinksAdded, conflictsResolvedByDecision),
|
||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
|
|||||||
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReseedColorVarietiesGen3f : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000071"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000072"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000073"));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmch DD EE GG PP spsp rere", "Siam" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmch DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "CP-Agouti" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "CP-Silberagouti" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "CP-Algierfuchs" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "CP-Polarfuchs" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm dd ee GG PP spsp rere", "CP-Fuchs" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmch dd ee GG PP spsp rere", "CP-Fuchs-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm dd ee gg PP spsp rere", "CP-Blaufuchs" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
columns: new[] { "CanonicalGenotype", "Name" },
|
||||||
|
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 61 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 62 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 63 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 64 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000066"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 65 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 66 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 67 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC dd EE GG pp spsp rere", "Topas dd", 68 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000070"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 69 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 70 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa CC dd ee gg pp spsp rere", "Blaufuchs dd", 71 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000073"), "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel", 72 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -343,352 +343,268 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000024"),
|
Id = new Guid("00000000-0000-0000-0000-000000000024"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||||
Name = "Marder",
|
Name = "Polarfuchs",
|
||||||
SortOrder = 23
|
SortOrder = 23
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000025"),
|
Id = new Guid("00000000-0000-0000-0000-000000000025"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||||
Name = "Siam (Marder-Hell)",
|
Name = "Saphir",
|
||||||
SortOrder = 24
|
SortOrder = 24
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000026"),
|
Id = new Guid("00000000-0000-0000-0000-000000000026"),
|
||||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||||
Name = "Polarfuchs",
|
Name = "Orangeschimmel",
|
||||||
SortOrder = 25
|
SortOrder = 25
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000027"),
|
Id = new Guid("00000000-0000-0000-0000-000000000027"),
|
||||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||||
Name = "Saphir",
|
Name = "Topas",
|
||||||
SortOrder = 26
|
SortOrder = 26
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000028"),
|
Id = new Guid("00000000-0000-0000-0000-000000000028"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||||
Name = "Orangeschimmel",
|
Name = "Platin-Hell",
|
||||||
SortOrder = 27
|
SortOrder = 27
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||||
Name = "Topas",
|
Name = "Agouti dd",
|
||||||
SortOrder = 28
|
SortOrder = 28
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||||
Name = "Platin-Hell",
|
Name = "Silberagouti dd",
|
||||||
SortOrder = 29
|
SortOrder = 29
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||||
Name = "Agouti dd",
|
Name = "Kohlfuchs dd",
|
||||||
SortOrder = 30
|
SortOrder = 30
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||||
Name = "Silberagouti dd",
|
Name = "Anthrazit dd",
|
||||||
SortOrder = 31
|
SortOrder = 31
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Kohlfuchs dd",
|
Name = "Silberschimmel",
|
||||||
SortOrder = 32
|
SortOrder = 32
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Anthrazit dd",
|
Name = "Polarfuchsschimmel",
|
||||||
SortOrder = 33
|
SortOrder = 33
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||||
Name = "Agouti CP-Hell",
|
Name = "Algierfuchsschimmel",
|
||||||
SortOrder = 34
|
SortOrder = 34
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Blaufuchs CP",
|
Name = "Kohlfuchsschimmel",
|
||||||
SortOrder = 35
|
SortOrder = 35
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||||
Name = "Silberschimmel",
|
Name = "Blaufuchsschimmel",
|
||||||
SortOrder = 36
|
SortOrder = 36
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Polarfuchsschimmel",
|
Name = "Kohlfuchs, hell",
|
||||||
SortOrder = 37
|
SortOrder = 37
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||||
Name = "Algierfuchsschimmel",
|
Name = "Goldfuchs, hell",
|
||||||
SortOrder = 38
|
SortOrder = 38
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||||
Name = "Polarfuchs-Hell CP",
|
Name = "Goldfuchsschimmel",
|
||||||
SortOrder = 39
|
SortOrder = 39
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||||
Name = "Kohlfuchsschimmel",
|
Name = "Gold-Hell",
|
||||||
SortOrder = 40
|
SortOrder = 40
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||||
Name = "Blaufuchsschimmel",
|
Name = "Blaufuchs, hell",
|
||||||
SortOrder = 41
|
SortOrder = 41
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||||
Name = "Kohlfuchs, hell",
|
Name = "Rotfuchsschimmel",
|
||||||
SortOrder = 42
|
SortOrder = 42
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||||
Name = "Goldfuchs, hell",
|
Name = "Polarfuchs, hell",
|
||||||
SortOrder = 43
|
SortOrder = 43
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Goldfuchsschimmel",
|
Name = "Kohlfuchsschimmel, hell",
|
||||||
SortOrder = 44
|
SortOrder = 44
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||||
Name = "Gold-Hell",
|
Name = "Rotfuchs, hell",
|
||||||
SortOrder = 45
|
SortOrder = 45
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Siam (Marder-Hell) dd",
|
Name = "Kohlfuchs-Hell",
|
||||||
SortOrder = 46
|
SortOrder = 46
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||||
Name = "Marder dd",
|
Name = "Algierfuchs, hell",
|
||||||
SortOrder = 47
|
SortOrder = 47
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "Zobel-Hell",
|
Name = "Topas dd",
|
||||||
SortOrder = 48
|
SortOrder = 48
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||||
Name = "Silberagouti dd CP",
|
Name = "Blaufuchs dd",
|
||||||
SortOrder = 49
|
SortOrder = 49
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "Silberagouti-Hell dd CP",
|
Name = "Marder",
|
||||||
SortOrder = 50
|
SortOrder = 50
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
||||||
Name = "Agouti dd CP",
|
Name = "Siam",
|
||||||
SortOrder = 51
|
SortOrder = 51
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
||||||
Name = "Agouti-Hell dd CP",
|
Name = "Zobel-Hell",
|
||||||
SortOrder = 52
|
SortOrder = 52
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "Blaufuchs, hell",
|
Name = "CP-Agouti",
|
||||||
SortOrder = 53
|
SortOrder = 53
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
Name = "Rotfuchsschimmel",
|
Name = "CP-Silberagouti",
|
||||||
SortOrder = 54
|
SortOrder = 54
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
Name = "Polarfuchs, hell",
|
Name = "CP-Algierfuchs",
|
||||||
SortOrder = 55
|
SortOrder = 55
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel, hell",
|
Name = "CP-Polarfuchs",
|
||||||
SortOrder = 56
|
SortOrder = 56
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
Name = "Rotfuchs, hell",
|
Name = "CP-Fuchs",
|
||||||
SortOrder = 57
|
SortOrder = 57
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
||||||
Name = "Zobel dd",
|
Name = "CP-Fuchs-Hell",
|
||||||
SortOrder = 58
|
SortOrder = 58
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
Name = "Kohlfuchs-Hell",
|
Name = "CP-Blaufuchs",
|
||||||
SortOrder = 59
|
SortOrder = 59
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
|
||||||
Name = "Kohlfuchs CP",
|
|
||||||
SortOrder = 60
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
|
||||||
Name = "Algierfuchs CP",
|
|
||||||
SortOrder = 61
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
|
||||||
Name = "Silberagouti CP",
|
|
||||||
SortOrder = 62
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
|
||||||
Name = "Agouti CP",
|
|
||||||
SortOrder = 63
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
|
||||||
Name = "Algierfuchs-Hell CP",
|
|
||||||
SortOrder = 64
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
|
||||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
|
||||||
Name = "Kohlfuchs,hell CP",
|
|
||||||
SortOrder = 65
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
|
||||||
Name = "Polarfuchs CP",
|
|
||||||
SortOrder = 66
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
|
||||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
|
||||||
Name = "Algierfuchs, hell",
|
|
||||||
SortOrder = 67
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
|
||||||
Name = "Topas dd",
|
|
||||||
SortOrder = 68
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
|
||||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
|
||||||
Name = "Zobel-Hell dd",
|
|
||||||
SortOrder = 69
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000071"),
|
|
||||||
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
|
|
||||||
Name = "Kohlfuchsschimmel CP",
|
|
||||||
SortOrder = 70
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000072"),
|
|
||||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
|
||||||
Name = "Blaufuchs dd",
|
|
||||||
SortOrder = 71
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000073"),
|
|
||||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel",
|
Name = "CP-Orangeschimmel",
|
||||||
SortOrder = 72
|
SortOrder = 60
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|||||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
||||||
| 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 | **„Himalaya" vs. „Hermelin":** Du hast gesagt `c[h]c[h]` = **Hermelin**. In unserem Katalog gibt es aktuell ZWEI Farben mit `c[h]c[h]`: **Hermelin** (`aa c[h]c[h]`, nicht-agouti) und **Himalaya** (`A- c[h]c[h]`, agouti). Gibt es bei dir „Himalaya" überhaupt, oder ist **alles** mit `c[h]c[h]` einfach **Hermelin** (dann nehmen wir „Himalaya" raus, wie bei Schwarzschimmel)? | Farbschlag-Katalog (Himalaya behalten oder entfernen) |
|
| C8 | **„Himalaya" vs. „Hermelin":** Du hast gesagt `c[h]c[h]` = **Hermelin**. In unserem Katalog gibt es aktuell ZWEI Farben mit `c[h]c[h]`: **Hermelin** (`aa c[h]c[h]`, nicht-agouti) und **Himalaya** (`A- c[h]c[h]`, agouti). Gibt es bei dir „Himalaya" überhaupt, oder ist **alles** mit `c[h]c[h]` einfach **Hermelin** (dann nehmen wir „Himalaya" raus, wie bei Schwarzschimmel)? | Farbschlag-Katalog (Himalaya behalten oder entfernen) |
|
||||||
|
| C9 | *(optional, technisch)* Bei den **CP-Fuchs-Farben**: Wodurch unterscheiden sich genetisch **CP-Fuchs** ↔ **CP-Blaufuchs** ↔ **CP-Fuchs-Hell**? (Vermutung: Blaufuchs = `dd`-Verdünnung, „-Hell" = `c[chm]c[h]` statt `c[chm]c[chm]` — stimmt das?) Aktuell rechnet das Programm alle drei als „CP-Fuchs"; mit deiner Regel können wir sie genau unterscheiden. Per Hand auswählbar sind sie schon. | Farb-Engine Feinschliff (niedrige Priorität) |
|
||||||
|
|
||||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
||||||
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
||||||
@@ -64,35 +65,58 @@ Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
|||||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
||||||
|
|
||||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
||||||
| Tier | im Farbschlag steht fälschlich |
|
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
||||||
|---|---|
|
|
||||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" |
|
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
||||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) |
|
|---|---|---|
|
||||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) |
|
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
||||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" |
|
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
||||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) |
|
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
||||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) |
|
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
||||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) |
|
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
||||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" |
|
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) |
|
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) |
|
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" |
|
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
||||||
|
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
||||||
|
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
||||||
|
|
||||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
||||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
||||||
|
|
||||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
||||||
Firefly v.d. K.C. (*18.12.2019, DD↔D-) · Zuleika v.d. K.C. (*24.10.2015) · WildFire v.d. K.C. (*05.10.2017, PP↔P-) · Milka of LennyLengo (*09.12.2018) · Silvain v.d. K.C. (*27.03.2022) · Daja of Little Rose (*16.05.2021, spsp) · Ichika v.d. K.C. (*19.04.2020) · Chelsea v.d. K.C. (*02.04.2021)
|
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
||||||
|
|
||||||
|
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
||||||
|
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
||||||
|
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
||||||
|
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
||||||
|
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
||||||
|
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
||||||
|
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
||||||
|
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
||||||
|
|
||||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
||||||
Vestra von den Schlossmäusen (*08.02.2019, WP) · Hedwig of BGB (*30.10.2019, DP + hörend) · Pitari gen. Piti v.d. K.C. (*16.05.2021, DP) · Little Hero of Black Forest (*22.02.2018, WFNZ ± spsp) · Victoria Welby gen. Welby v.d. K.C. (*16.01.2023, DP + Genotyp Ee[f]↔ee[f])
|
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
||||||
|
>
|
||||||
|
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
||||||
|
|
||||||
|
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
||||||
|
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** (`Aa CC D- ee[f] Gg pp Spsp [DP]`) — Julian → C bekommt damit seine Mutter |
|
||||||
|
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
||||||
|
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
||||||
|
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
||||||
|
|
||||||
### D5 · **Sterbedatum** widersprüchlich
|
### D5 · **Sterbedatum** widersprüchlich
|
||||||
| Tier | Problem |
|
| Tier | Problem |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Flint v.d. Kleinen Chaoten (*23.12.2017) | Tod 10.05.**2021** vs. 10.05.**2022** (Jahr-Tippfehler?) |
|
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
||||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
||||||
| Molly of Black Forest (*13.09.2021) | Tod **03.05.2021 — VOR der Geburt** → klarer Datenfehler, bitte korrigieren |
|
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
||||||
|
|
||||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
||||||
|
|
||||||
|
|||||||
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl.
|
||||||
|
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||||
|
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||||
|
*/
|
||||||
|
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const ta = de.pages.anfragen
|
||||||
|
const td = ta.detail
|
||||||
|
|
||||||
|
test.describe('Anfragen', () => {
|
||||||
|
test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.requests)
|
||||||
|
await expect(page.getByRole('heading', { name: ta.title })).toBeVisible()
|
||||||
|
|
||||||
|
// Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.)
|
||||||
|
const cards = page.locator('.anfrage-card')
|
||||||
|
await expect(cards).toHaveCount(3)
|
||||||
|
await expect(cards.nth(0)).toContainText('Anna Albrecht')
|
||||||
|
await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse
|
||||||
|
|
||||||
|
// Status-Badge auf der Karte
|
||||||
|
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||||
|
|
||||||
|
// Filter: nur Beantwortet
|
||||||
|
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||||
|
await expect(cards).toHaveCount(1)
|
||||||
|
await expect(cards.first()).toContainText('Danke!')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
mockDb!.mailConfigured = false
|
||||||
|
await page.goto('/anfragen')
|
||||||
|
await page.getByRole('button', { name: ta.sync }).click()
|
||||||
|
await expect(page.getByText(ta.mailNotConfigured)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/anfragen/req-anna')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible()
|
||||||
|
await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' })
|
||||||
|
await expect(page.locator('.anfrage-badge--assigned')).toBeVisible()
|
||||||
|
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||||
|
|
||||||
|
// Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt
|
||||||
|
acceptNextDialog(page)
|
||||||
|
await page.getByRole('button', { name: td.abandon }).click()
|
||||||
|
await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible()
|
||||||
|
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/anfragen/req-anna')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: td.draftButton }).click()
|
||||||
|
const textarea = page.locator('.anfrage-reply__text')
|
||||||
|
await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/)
|
||||||
|
|
||||||
|
// Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis
|
||||||
|
await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!')
|
||||||
|
acceptNextDialog(page)
|
||||||
|
await page.getByRole('button', { name: td.send }).click()
|
||||||
|
await expect(page.getByText(td.sent)).toBeVisible()
|
||||||
|
await expect(page.locator('.anfrage-badge--answered')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
mockDb!.aiConfigured = false
|
||||||
|
await page.goto('/anfragen/req-anna')
|
||||||
|
await page.getByRole('button', { name: td.draftButton }).click()
|
||||||
|
await expect(page.getByText(td.aiKeyMissing)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/anfragen/req-anna')
|
||||||
|
await page.getByRole('button', { name: td.send }).click()
|
||||||
|
await expect(page.getByText(td.sendEmptyBody)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
43
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
|
test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Bestand ist sichtbar …
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
// … aber die externe Ahne „Max“ (isResident=false) nicht.
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||||
|
|
||||||
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
|
await expect(maxRow).toBeVisible()
|
||||||
|
await expect(maxRow.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||||
|
// Der Bestand bleibt weiterhin sichtbar.
|
||||||
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
|
||||||
|
await expect(check).toBeChecked()
|
||||||
|
await check.uncheck()
|
||||||
|
await page.getByRole('button', { name: t.form.save }).click()
|
||||||
|
|
||||||
|
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -135,6 +135,53 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
if (method === 'PUT') return json(route, 204)
|
if (method === 'PUT') return json(route, 204)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
|
||||||
|
if (path === '/requests/sync' && method === 'POST') {
|
||||||
|
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
|
||||||
|
}
|
||||||
|
let rm = path.match(/^\/requests\/([^/]+)\/draft$/)
|
||||||
|
if (rm && method === 'POST') {
|
||||||
|
if (!db.aiConfigured) {
|
||||||
|
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||||
|
}
|
||||||
|
const r = db.requests.find((x) => x.id === rm![1])
|
||||||
|
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||||
|
r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße`
|
||||||
|
return json(route, 200, r)
|
||||||
|
}
|
||||||
|
rm = path.match(/^\/requests\/([^/]+)\/send$/)
|
||||||
|
if (rm && method === 'POST') {
|
||||||
|
if (!db.mailConfigured) {
|
||||||
|
return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' })
|
||||||
|
}
|
||||||
|
const r = db.requests.find((x) => x.id === rm![1])
|
||||||
|
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||||
|
r.status = 'Answered'
|
||||||
|
r.answeredAt = '2026-06-06T10:00:00Z'
|
||||||
|
return json(route, 200, r)
|
||||||
|
}
|
||||||
|
rm = path.match(/^\/requests(?:\/([^/]+))?$/)
|
||||||
|
if (rm) {
|
||||||
|
const reqId = rm[1] ? decodeURIComponent(rm[1]) : null
|
||||||
|
if (!reqId && method === 'GET') {
|
||||||
|
return json(route, 200, pagedResponse(db.requests as unknown as Row[], url))
|
||||||
|
}
|
||||||
|
const r = db.requests.find((x) => x.id === reqId)
|
||||||
|
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||||
|
if (method === 'GET') return json(route, 200, r)
|
||||||
|
if (method === 'PUT') {
|
||||||
|
// Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen.
|
||||||
|
const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null }
|
||||||
|
r.assignedContactId = body.assignedContactId ?? null
|
||||||
|
if (body.status) {
|
||||||
|
r.status = body.status as typeof r.status
|
||||||
|
if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z'
|
||||||
|
}
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
|
||||||
// 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') {
|
||||||
@@ -168,6 +215,81 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen,
|
||||||
|
// weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind.
|
||||||
|
const toSummary = (p: (typeof db.pages)[number]) => ({
|
||||||
|
id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status,
|
||||||
|
})
|
||||||
|
const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({
|
||||||
|
id: b.id, order: b.order, type: b.type, data: b.data,
|
||||||
|
})
|
||||||
|
const toPage = (p: (typeof db.pages)[number]) => ({
|
||||||
|
...toSummary(p),
|
||||||
|
blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (path === '/pages' && method === 'GET') {
|
||||||
|
return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary))
|
||||||
|
}
|
||||||
|
// GET /pages/{slug} · PUT /pages/{id}
|
||||||
|
m = path.match(/^\/pages\/([^/]+)$/)
|
||||||
|
if (m && method === 'GET') {
|
||||||
|
const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1]))
|
||||||
|
return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' })
|
||||||
|
}
|
||||||
|
// PUT /pages/{id}
|
||||||
|
if (m && method === 'PUT') {
|
||||||
|
const p = db.pages.find((x) => x.id === m![1])
|
||||||
|
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||||
|
const body = request.postDataJSON() as Partial<typeof p>
|
||||||
|
if (typeof body.slug === 'string') p.slug = body.slug
|
||||||
|
if (typeof body.title === 'string') p.title = body.title
|
||||||
|
p.seoDescription = (body.seoDescription as string | null) ?? null
|
||||||
|
if (body.status === 'Draft' || body.status === 'Published') p.status = body.status
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
// POST /pages/{pageId}/blocks
|
||||||
|
m = path.match(/^\/pages\/([^/]+)\/blocks$/)
|
||||||
|
if (m && method === 'POST') {
|
||||||
|
const p = db.pages.find((x) => x.id === m![1])
|
||||||
|
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||||
|
const body = request.postDataJSON() as { type: string; data?: Record<string, unknown>; order?: number }
|
||||||
|
const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1)
|
||||||
|
const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} }
|
||||||
|
p.blocks.push(block)
|
||||||
|
return json(route, 201, toBlock(block))
|
||||||
|
}
|
||||||
|
// PUT /pages/{pageId}/blocks/order (Body: { blockIds })
|
||||||
|
m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/)
|
||||||
|
if (m && method === 'PUT') {
|
||||||
|
const p = db.pages.find((x) => x.id === m![1])
|
||||||
|
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||||
|
const { blockIds } = request.postDataJSON() as { blockIds: string[] }
|
||||||
|
const byId = new Map(p.blocks.map((b) => [b.id, b]))
|
||||||
|
if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id)))
|
||||||
|
return json(route, 400, 'blockIds must list exactly the page block ids.')
|
||||||
|
blockIds.forEach((id, i) => { byId.get(id)!.order = i })
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
// PUT/DELETE /blocks/{id}
|
||||||
|
m = path.match(/^\/blocks\/([^/]+)$/)
|
||||||
|
if (m) {
|
||||||
|
const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1]))
|
||||||
|
const block = pageOf?.blocks.find((b) => b.id === m![1])
|
||||||
|
if (!pageOf || !block) return json(route, 404, { title: 'Not Found' })
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const body = request.postDataJSON() as { type?: string; data?: Record<string, unknown>; order?: number }
|
||||||
|
if (typeof body.type === 'string') block.type = body.type
|
||||||
|
if (body.data) block.data = body.data
|
||||||
|
if (typeof body.order === 'number') block.order = body.order
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1])
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* FEAT-2/FEAT-6-Flüsse.
|
* FEAT-2/FEAT-6-Flüsse.
|
||||||
*/
|
*/
|
||||||
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
||||||
|
import type { InboxRequest } from '../src/api/requests'
|
||||||
|
|
||||||
export interface HealthRecordRow {
|
export interface HealthRecordRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -25,6 +26,24 @@ export interface WeightRecordRow {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WEB-0b: CMS-Block (data ist das typ-spezifische JSON-Objekt). */
|
||||||
|
export interface MockBlock {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
type: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WEB-0b: CMS-Seite der öffentlichen Webseite. */
|
||||||
|
export interface MockPage {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
seoDescription: string | null
|
||||||
|
status: 'Draft' | 'Published'
|
||||||
|
blocks: MockBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
gerbils: Gerbil[]
|
gerbils: Gerbil[]
|
||||||
litters: Litter[]
|
litters: Litter[]
|
||||||
@@ -33,6 +52,12 @@ export interface MockDb {
|
|||||||
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||||
healthRecords: HealthRecordRow[]
|
healthRecords: HealthRecordRow[]
|
||||||
weightRecords: WeightRecordRow[]
|
weightRecords: WeightRecordRow[]
|
||||||
|
pages: MockPage[]
|
||||||
|
// INBOX-1: Anfragen-Posteingang. Die Flags steuern die Fehlerpfade des Mocks
|
||||||
|
// (Specs können sie pro Test umlegen): KI-Entwurf 503 / Sync+Send Mail-Fehler.
|
||||||
|
requests: InboxRequest[]
|
||||||
|
aiConfigured: boolean
|
||||||
|
mailConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -63,6 +88,8 @@ function gerbil(
|
|||||||
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
||||||
genotype,
|
genotype,
|
||||||
notes: null,
|
notes: null,
|
||||||
|
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||||
|
isResident: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +112,9 @@ export function seedDb(): MockDb {
|
|||||||
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
||||||
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
||||||
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
||||||
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'),
|
// BESTAND-FILTER: externe Ahnen (Gründertiere fremder Zuchten, nur für den Stammbaum).
|
||||||
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'),
|
{ ...gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||||
|
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
@@ -142,5 +170,93 @@ export function seedDb(): MockDb {
|
|||||||
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords }
|
// WEB-0b: die 6 Webseiten-Seiten (wie vom Backend geseedet), mit ein paar Blöcken.
|
||||||
|
const pages: MockPage[] = [
|
||||||
|
{
|
||||||
|
id: 'page-start',
|
||||||
|
slug: 'start',
|
||||||
|
title: 'Startseite',
|
||||||
|
seoDescription: 'Willkommen bei unserer Rennmauszucht.',
|
||||||
|
status: 'Published',
|
||||||
|
blocks: [
|
||||||
|
{ id: 'blk-start-1', order: 0, type: 'Heading', data: { text: 'Willkommen', level: 2 } },
|
||||||
|
{ id: 'blk-start-2', order: 1, type: 'RichText', data: { markdown: 'Schön, dass du da bist.' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'page-zucht', slug: 'ueber-die-zucht', title: 'Über die Zucht', seoDescription: null, status: 'Draft', blocks: [] },
|
||||||
|
{
|
||||||
|
id: 'page-abgabe',
|
||||||
|
slug: 'abgabetiere',
|
||||||
|
title: 'Abgabetiere',
|
||||||
|
seoDescription: null,
|
||||||
|
status: 'Published',
|
||||||
|
blocks: [
|
||||||
|
{ id: 'blk-abgabe-1', order: 0, type: 'AbgabetiereList', data: { mode: 'auto', intro: 'Diese Tiere suchen ein Zuhause.' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'page-bedingungen', slug: 'abgabebedingungen', title: 'Abgabebedingungen', seoDescription: null, status: 'Draft', blocks: [] },
|
||||||
|
{ id: 'page-farben', slug: 'farben-genetik', title: 'Farben & Genetik', seoDescription: null, status: 'Draft', blocks: [] },
|
||||||
|
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
|
||||||
|
]
|
||||||
|
|
||||||
|
// INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar)
|
||||||
|
const requests: InboxRequest[] = [
|
||||||
|
{
|
||||||
|
id: 'req-anna',
|
||||||
|
gmailMessageId: '<anna-1@mail.example>',
|
||||||
|
threadId: 'thr-1',
|
||||||
|
fromAddress: 'anna@example.de',
|
||||||
|
fromName: 'Anna Albrecht',
|
||||||
|
subject: 'Anfrage: Pärchen zur Abgabe?',
|
||||||
|
bodyText:
|
||||||
|
'Hallo,\n\nich habe euer Inserat gesehen — sind die beiden Agouti-Jungs noch zu haben?\n\nViele Grüße\nAnna',
|
||||||
|
receivedAt: '2026-06-05T18:30:00Z',
|
||||||
|
status: 'New',
|
||||||
|
assignedContactId: null,
|
||||||
|
draftReply: null,
|
||||||
|
answeredAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'req-ben',
|
||||||
|
gmailMessageId: '<ben-1@mail.example>',
|
||||||
|
threadId: 'thr-2',
|
||||||
|
fromAddress: 'ben@example.org',
|
||||||
|
fromName: 'Ben Berger',
|
||||||
|
subject: 'Frage zur Haltung',
|
||||||
|
bodyText: 'Guten Tag, was für ein Becken empfehlt ihr für zwei Rennmäuse?',
|
||||||
|
receivedAt: '2026-06-04T09:00:00Z',
|
||||||
|
status: 'InProgress',
|
||||||
|
assignedContactId: null,
|
||||||
|
draftReply: 'Hallo Ben, wir empfehlen mindestens 100×50 cm …',
|
||||||
|
answeredAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'req-clara',
|
||||||
|
gmailMessageId: '<clara-1@mail.example>',
|
||||||
|
threadId: null,
|
||||||
|
fromAddress: 'clara@example.com',
|
||||||
|
fromName: null,
|
||||||
|
subject: 'Danke!',
|
||||||
|
bodyText: 'Die beiden sind super angekommen — danke nochmal!',
|
||||||
|
receivedAt: '2026-06-01T12:00:00Z',
|
||||||
|
status: 'Answered',
|
||||||
|
assignedContactId: 'con-meier',
|
||||||
|
draftReply: null,
|
||||||
|
answeredAt: '2026-06-02T08:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
gerbils,
|
||||||
|
litters,
|
||||||
|
enclosures,
|
||||||
|
contacts,
|
||||||
|
colorVarieties,
|
||||||
|
healthRecords,
|
||||||
|
weightRecords,
|
||||||
|
pages,
|
||||||
|
requests,
|
||||||
|
aiConfigured: true,
|
||||||
|
mailConfigured: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/** WEB-0b: CMS-Verwaltung der öffentlichen Webseite — Smoke-Test. */
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.webseite
|
||||||
|
|
||||||
|
test('Webseiten-Übersicht zeigt die Seiten mit Status', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.website)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
const cards = page.locator('.webseite-card')
|
||||||
|
await expect(cards).toHaveCount(6)
|
||||||
|
|
||||||
|
const start = page.locator('.webseite-card', { hasText: 'Startseite' })
|
||||||
|
await expect(start.getByText(t.statusPublished, { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Block hinzufügen, bearbeiten und verschieben bleibt erhalten', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.website)
|
||||||
|
|
||||||
|
// Startseite öffnen (hat bereits zwei Blöcke).
|
||||||
|
await page
|
||||||
|
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||||
|
.getByRole('link', { name: t.edit })
|
||||||
|
.click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
||||||
|
|
||||||
|
const blocks = page.locator('.block-card')
|
||||||
|
await expect(blocks).toHaveCount(2)
|
||||||
|
|
||||||
|
// Eine Überschrift hinzufügen -> jetzt drei Blöcke.
|
||||||
|
await page.getByRole('button', { name: `+ ${t.blocks.types.Heading}` }).click()
|
||||||
|
await expect(blocks).toHaveCount(3)
|
||||||
|
|
||||||
|
// Den neuen (letzten) Block beschriften und speichern.
|
||||||
|
const last = blocks.last()
|
||||||
|
await last.getByLabel(t.blocks.fields.headingText).fill('Neue Überschrift')
|
||||||
|
await last.getByRole('button', { name: t.blocks.save }).click()
|
||||||
|
await expect(last.getByText(t.blocks.saved, { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Den neuen Block nach oben verschieben (von Position 3 auf 2).
|
||||||
|
await last.getByRole('button', { name: t.blocks.moveUp }).click()
|
||||||
|
|
||||||
|
// Neu laden über die Übersicht — der Mock ist zustandsbehaftet.
|
||||||
|
await page.getByRole('link', { name: t.back }).click()
|
||||||
|
await page
|
||||||
|
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||||
|
.getByRole('link', { name: t.edit })
|
||||||
|
.click()
|
||||||
|
|
||||||
|
// Drei Blöcke, und die neue Überschrift steht jetzt an zweiter Stelle.
|
||||||
|
await expect(page.locator('.block-card')).toHaveCount(3)
|
||||||
|
await expect(page.locator('.block-card').nth(1).getByLabel(t.blocks.fields.headingText)).toHaveValue(
|
||||||
|
'Neue Überschrift',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -22,6 +22,10 @@ import HilfePage from './pages/HilfePage'
|
|||||||
import VertraegeListPage from './pages/VertraegeListPage'
|
import VertraegeListPage from './pages/VertraegeListPage'
|
||||||
import VertragWizardPage from './pages/VertragWizardPage'
|
import VertragWizardPage from './pages/VertragWizardPage'
|
||||||
import EinstellungenPage from './pages/EinstellungenPage'
|
import EinstellungenPage from './pages/EinstellungenPage'
|
||||||
|
import WebseitePage from './pages/WebseitePage'
|
||||||
|
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
||||||
|
import AnfragenPage from './pages/AnfragenPage'
|
||||||
|
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -65,6 +69,16 @@ export default function App() {
|
|||||||
<Route path="neu" element={<VertragWizardPage />} />
|
<Route path="neu" element={<VertragWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="einstellungen" element={<EinstellungenPage />} />
|
<Route path="einstellungen" element={<EinstellungenPage />} />
|
||||||
|
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
|
||||||
|
<Route path="webseite">
|
||||||
|
<Route index element={<WebseitePage />} />
|
||||||
|
<Route path=":slug" element={<WebseiteEditorPage />} />
|
||||||
|
</Route>
|
||||||
|
{/* INBOX-1: Anfragen-Posteingang */}
|
||||||
|
<Route path="anfragen">
|
||||||
|
<Route index element={<AnfragenPage />} />
|
||||||
|
<Route path=":id" element={<AnfrageDetailPage />} />
|
||||||
|
</Route>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ADDABLE_BLOCK_TYPES, defaultBlockData, type BlockType } from '../pages'
|
||||||
|
|
||||||
|
describe('defaultBlockData', () => {
|
||||||
|
it('liefert sinnvolle Startwerte je Block-Typ', () => {
|
||||||
|
expect(defaultBlockData('Heading')).toEqual({ text: '', level: 2 })
|
||||||
|
expect(defaultBlockData('RichText')).toEqual({ markdown: '' })
|
||||||
|
expect(defaultBlockData('Image')).toEqual({ url: '', alt: '' })
|
||||||
|
expect(defaultBlockData('Gallery')).toEqual({ images: [] })
|
||||||
|
expect(defaultBlockData('ContactInfo')).toEqual({ name: '', email: '', phone: '', address: '' })
|
||||||
|
expect(defaultBlockData('AbgabetiereList')).toEqual({ mode: 'auto', intro: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deckt jeden addbaren Typ ab', () => {
|
||||||
|
for (const type of ADDABLE_BLOCK_TYPES) {
|
||||||
|
expect(defaultBlockData(type)).toBeTypeOf('object')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bietet die dynamische AbgabetiereList NICHT zum manuellen Anlegen an', () => {
|
||||||
|
const addable: readonly BlockType[] = ADDABLE_BLOCK_TYPES
|
||||||
|
expect(addable).not.toContain('AbgabetiereList')
|
||||||
|
})
|
||||||
|
})
|
||||||
149
gerbil-manager-web/src/api/pages.ts
Normal file
149
gerbil-manager-web/src/api/pages.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* WEB-0b: CMS-Inhalte der öffentlichen Webseite (Seiten + Blöcke).
|
||||||
|
*
|
||||||
|
* Backend-Vertrag (GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs):
|
||||||
|
* - GET /pages -> PageSummary[] (kein Paging!)
|
||||||
|
* - GET /pages/{slug} -> Page (inkl. Blöcke)
|
||||||
|
* - PUT /pages/{id} -> 204
|
||||||
|
* - POST /pages/{pageId}/blocks -> 201 Block
|
||||||
|
* - PUT /blocks/{id} -> 204
|
||||||
|
* - DELETE /blocks/{id} -> 204
|
||||||
|
* - PUT /pages/{pageId}/blocks/order -> 204 (Body: { blockIds })
|
||||||
|
*
|
||||||
|
* Enums kommen dank globalem JsonStringEnumConverter als STRINGS
|
||||||
|
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
|
||||||
|
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
|
||||||
|
*/
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export type PageStatus = 'Draft' | 'Published'
|
||||||
|
|
||||||
|
export type BlockType =
|
||||||
|
| 'Heading'
|
||||||
|
| 'RichText'
|
||||||
|
| 'Image'
|
||||||
|
| 'Gallery'
|
||||||
|
| 'ContactInfo'
|
||||||
|
| 'AbgabetiereList'
|
||||||
|
|
||||||
|
/** Block-Typen, die die Hand-Editier-Oberfläche neu anlegen kann (in Anzeige-Reihenfolge). */
|
||||||
|
export const ADDABLE_BLOCK_TYPES: readonly BlockType[] = [
|
||||||
|
'Heading',
|
||||||
|
'RichText',
|
||||||
|
'Image',
|
||||||
|
'Gallery',
|
||||||
|
'ContactInfo',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// ── Typ-spezifische Block-Daten ────────────────────────────────────────────
|
||||||
|
export interface HeadingData {
|
||||||
|
text: string
|
||||||
|
level: 2 | 3
|
||||||
|
}
|
||||||
|
export interface RichTextData {
|
||||||
|
/** Markdown-Quelltext (wird erst beim Veröffentlichen gerendert + bereinigt). */
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
export interface ImageData {
|
||||||
|
url: string
|
||||||
|
alt: string
|
||||||
|
}
|
||||||
|
export interface GalleryImage {
|
||||||
|
url: string
|
||||||
|
alt: string
|
||||||
|
}
|
||||||
|
export interface GalleryData {
|
||||||
|
images: GalleryImage[]
|
||||||
|
}
|
||||||
|
export interface ContactInfoData {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
export interface AbgabetiereListData {
|
||||||
|
/** auto = aus den aktuellen Abgabetieren aufgelöst; manual = von Hand gepflegt. */
|
||||||
|
mode: 'auto' | 'manual'
|
||||||
|
intro: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlockData = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
type: BlockType
|
||||||
|
data: BlockData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageSummary {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
seoDescription: string | null
|
||||||
|
status: PageStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Page extends PageSummary {
|
||||||
|
blocks: Block[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageInput {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
seoDescription: string | null
|
||||||
|
status: PageStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockInput {
|
||||||
|
type: BlockType
|
||||||
|
data: BlockData
|
||||||
|
order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sinnvolle Startwerte für einen neu angelegten Block. */
|
||||||
|
export function defaultBlockData(type: BlockType): BlockData {
|
||||||
|
switch (type) {
|
||||||
|
case 'Heading':
|
||||||
|
return { text: '', level: 2 }
|
||||||
|
case 'RichText':
|
||||||
|
return { markdown: '' }
|
||||||
|
case 'Image':
|
||||||
|
return { url: '', alt: '' }
|
||||||
|
case 'Gallery':
|
||||||
|
return { images: [] }
|
||||||
|
case 'ContactInfo':
|
||||||
|
return { name: '', email: '', phone: '', address: '' }
|
||||||
|
case 'AbgabetiereList':
|
||||||
|
return { mode: 'auto', intro: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
|
||||||
|
export function listPages(): Promise<PageSummary[]> {
|
||||||
|
return api.get<PageSummary[]>('/pages')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPage(slug: string): Promise<Page> {
|
||||||
|
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePage(id: string, input: PageInput): Promise<void> {
|
||||||
|
return api.put<void>(`/pages/${id}`, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
|
||||||
|
return api.post<Block>(`/pages/${pageId}/blocks`, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBlock(id: string, input: BlockInput): Promise<void> {
|
||||||
|
return api.put<void>(`/blocks/${id}`, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlock(id: string): Promise<void> {
|
||||||
|
return api.delete(`/blocks/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
|
||||||
|
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds })
|
||||||
|
}
|
||||||
87
gerbil-manager-web/src/api/requests.ts
Normal file
87
gerbil-manager-web/src/api/requests.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* INBOX-1: Anfragen-Posteingang (/api/requests, Backend INBOX-0/2/3).
|
||||||
|
*
|
||||||
|
* Triage-Hinweis: PUT setzt assignedContactId UNBEDINGT aus dem Input
|
||||||
|
* (Backend überschreibt mit null, wenn das Feld fehlt) — beim reinen
|
||||||
|
* Status-Wechsel also IMMER die aktuelle Zuordnung mitsenden.
|
||||||
|
*/
|
||||||
|
import { ApiError, api } from './client'
|
||||||
|
import { toQueryString, type GridifyQuery } from './gridify'
|
||||||
|
import type { Paged } from './types'
|
||||||
|
|
||||||
|
/** C#-Enum RequestStatus — Stringnamen auf dem Draht. */
|
||||||
|
export type RequestStatus = 'New' | 'InProgress' | 'Assigned' | 'Answered' | 'Abandoned'
|
||||||
|
export const REQUEST_STATUSES: RequestStatus[] = [
|
||||||
|
'New',
|
||||||
|
'InProgress',
|
||||||
|
'Assigned',
|
||||||
|
'Answered',
|
||||||
|
'Abandoned',
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface InboxRequest {
|
||||||
|
id: string
|
||||||
|
gmailMessageId: string
|
||||||
|
threadId: string | null
|
||||||
|
fromAddress: string
|
||||||
|
fromName: string | null
|
||||||
|
subject: string | null
|
||||||
|
bodyText: string | null
|
||||||
|
receivedAt: string
|
||||||
|
status: RequestStatus
|
||||||
|
assignedContactId: string | null
|
||||||
|
draftReply: string | null
|
||||||
|
answeredAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestTriage {
|
||||||
|
status?: RequestStatus | null
|
||||||
|
assignedContactId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis von POST /api/requests/sync. */
|
||||||
|
export interface SyncResult {
|
||||||
|
imported: number
|
||||||
|
/** null = ok; "MailNotConfigured" | "MailAuthFailed" | … */
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api/requests'
|
||||||
|
|
||||||
|
export function listRequests(query: GridifyQuery): Promise<Paged<InboxRequest>> {
|
||||||
|
return api.get<Paged<InboxRequest>>(`${BASE}${toQueryString(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequest(id: string): Promise<InboxRequest> {
|
||||||
|
return api.get<InboxRequest>(`${BASE}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triageRequest(id: string, triage: RequestTriage): Promise<void> {
|
||||||
|
return api.put<void>(`${BASE}/${id}`, triage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncRequests(): Promise<SyncResult> {
|
||||||
|
return api.post<SyncResult>(`${BASE}/sync`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KI-Entwurf erzeugen (INBOX-2); 503 {code:'AiKeyMissing'} solange unkonfiguriert. */
|
||||||
|
export function draftReply(id: string): Promise<InboxRequest> {
|
||||||
|
return api.post<InboxRequest>(`${BASE}/${id}/draft`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Bearbeitete) Antwort senden (INBOX-3); setzt serverseitig Answered. */
|
||||||
|
export function sendReply(id: string, body: string): Promise<InboxRequest> {
|
||||||
|
return api.post<InboxRequest>(`${BASE}/${id}/send`, { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProblemDetails-Titel aus einem ApiError lesen (Send-Endpunkt meldet
|
||||||
|
* MailNotConfigured/MailAuthFailed über `title`, nicht `code`).
|
||||||
|
*/
|
||||||
|
export function problemTitle(err: unknown): string | null {
|
||||||
|
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'title' in err.body) {
|
||||||
|
const title = (err.body as { title: unknown }).title
|
||||||
|
return typeof title === 'string' ? title : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ export interface Gerbil {
|
|||||||
*/
|
*/
|
||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
|
/**
|
||||||
|
* Residency (Bestand): true = part of the own clan (Kleine Chaoten), false =
|
||||||
|
* external/pedigree-only ancestor imported for the Stammbaum. Gridify-filterable
|
||||||
|
* (`isResident==true`); the Tiere list defaults to resident-only. Absent = treat
|
||||||
|
* as resident (backend default true).
|
||||||
|
*/
|
||||||
|
isResident?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -68,6 +75,7 @@ export interface CreateGerbil {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
|
isResident?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ const SECONDARY: NavItem[] = [
|
|||||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
||||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||||
|
// INBOX-1: Anfragen-Posteingang
|
||||||
|
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
|
||||||
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
||||||
// FEAT-13: Abgabeverträge + Zuchtprofil
|
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||||
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
||||||
|
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
|
||||||
|
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
|
||||||
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,16 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
if (!y || !m || !d) return iso
|
if (!y || !m || !d) return iso
|
||||||
return `${d}.${m}.${y}`
|
return `${d}.${m}.${y}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** INBOX-1: ISO-Zeitstempel → "TT.MM.JJJJ, HH:MM" (lokale Zeit, de-DE). */
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -179,8 +179,10 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has the expected catalogue coverage (GEN-2: frozen 18 + 55 portal varieties)', () => {
|
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
|
||||||
expect(CATALOG_SIZE).toBe(73)
|
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
||||||
|
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
||||||
|
expect(CATALOG_SIZE).toBe(61)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||||
@@ -206,7 +208,11 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(new Set(names).size).toBe(names.length)
|
expect(new Set(names).size).toBe(names.length)
|
||||||
for (const entry of BASE_COLORS) {
|
for (const entry of BASE_COLORS) {
|
||||||
for (const [locus, value] of Object.entries(entry.tokens)) {
|
for (const [locus, value] of Object.entries(entry.tokens)) {
|
||||||
expect(LOCI[locus as LocusKey].alleles).toContain(value)
|
// GEN-3f: a token may be a heterozygous pair "x/y" (e.g. C: 'cchm/ch'
|
||||||
|
// for the het colourpoints Siam/Zobel-Hell) — validate each allele.
|
||||||
|
for (const allele of value.split('/')) {
|
||||||
|
expect(LOCI[locus as LocusKey].alleles).toContain(allele)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -429,3 +435,46 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
|||||||
expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke')
|
expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her live data)', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||||
|
|
||||||
|
it('every CP- name her data uses exists as a catalog row (import name-match)', () => {
|
||||||
|
for (const n of [
|
||||||
|
'CP-Agouti', 'CP-Silberagouti', 'CP-Algierfuchs', 'CP-Polarfuchs',
|
||||||
|
'CP-Fuchs', 'CP-Fuchs-Hell', 'CP-Blaufuchs', 'CP-Orangeschimmel',
|
||||||
|
'Marder', 'Siam', 'Zobel', 'Zobel-Hell',
|
||||||
|
]) {
|
||||||
|
expect(has(n)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the removed portal suffix-names are gone (collapsed)', () => {
|
||||||
|
for (const n of [
|
||||||
|
'Agouti CP', 'Agouti CP-Hell', 'Silberagouti CP', 'Algierfuchs CP',
|
||||||
|
'Polarfuchs CP', 'Blaufuchs CP', 'Kohlfuchs CP', 'Kohlfuchsschimmel CP',
|
||||||
|
'Marder dd', 'Zobel dd', 'Siam (Marder-Hell)', 'Agouti dd CP',
|
||||||
|
]) {
|
||||||
|
expect(has(n)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the engine computes the agouti CP- names her data uses (E-family fallback covers fox/dilute)', () => {
|
||||||
|
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 ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||||||
|
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||||||
|
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
|
||||||
|
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
|
||||||
|
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||||
|
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', () => {
|
||||||
|
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||||
|
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' },
|
{ name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' },
|
||||||
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
|
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
|
||||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
|
||||||
{ name: 'Siam (Marder-Hell)', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
|
||||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||||
@@ -88,48 +86,47 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||||
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||||
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||||
{ name: 'Agouti CP-Hell', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp-hell.JPG' },
|
|
||||||
{ name: 'Blaufuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-cp.jpg' },
|
|
||||||
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
||||||
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
||||||
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
||||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||||
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
||||||
{ name: 'Polarfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell-cp.jpg' },
|
|
||||||
{ name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' },
|
{ name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' },
|
||||||
{ name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' },
|
{ name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' },
|
||||||
{ name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' },
|
{ name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' },
|
||||||
{ name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' },
|
{ name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' },
|
||||||
{ name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' },
|
{ name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' },
|
||||||
{ name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' },
|
{ name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' },
|
||||||
{ name: 'Siam (Marder-Hell) dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell-dd.jpg' },
|
|
||||||
{ name: 'Marder dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'marder-dd.jpg' },
|
|
||||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
|
||||||
{ name: 'Silberagouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd-cp.jpg' },
|
|
||||||
{ name: 'Silberagouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-hell-dd-cp.jpg' },
|
|
||||||
{ name: 'Agouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd-cp.jpg' },
|
|
||||||
{ name: 'Agouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-hell-dd-cp.jpg' },
|
|
||||||
{ name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' },
|
{ name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' },
|
||||||
{ name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' },
|
{ name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' },
|
||||||
{ name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' },
|
{ name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' },
|
||||||
{ name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' },
|
{ name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' },
|
||||||
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
||||||
{ name: 'Zobel dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-dd.jpg' },
|
|
||||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||||
{ name: 'Kohlfuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-cp.jpg' },
|
|
||||||
{ name: 'Algierfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
|
||||||
{ name: 'Silberagouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
|
||||||
{ name: 'Agouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
|
||||||
{ name: 'Algierfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell-cp.jpg' },
|
|
||||||
{ name: 'Kohlfuchs,hell CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-cp.jpg' },
|
|
||||||
{ name: 'Polarfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
|
||||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||||
{ 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: 'Zobel-Hell dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell-dd.jpg' },
|
|
||||||
{ name: 'Kohlfuchsschimmel CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-cp.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-3a: c[chm]c[chm] efef base = CP-Orangeschimmel (breeder C5). A unspecified
|
|
||||||
// (listed after the A-specific cchm-Schimmel entries so they win for those).
|
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
||||||
|
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
||||||
|
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
||||||
|
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
||||||
|
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
||||||
|
// in her live data (god: extract animals.json) so the re-import name-matches and
|
||||||
|
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
||||||
|
// 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: '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: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||||
|
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||||
|
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||||
|
{ 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-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' } },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -270,7 +267,14 @@ export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
|||||||
const out = {} as Record<LocusKey, AllelePair>
|
const out = {} as Record<LocusKey, AllelePair>
|
||||||
for (const locus of LOCUS_ORDER) {
|
for (const locus of LOCUS_ORDER) {
|
||||||
const token = entry.tokens[locus]
|
const token = entry.tokens[locus]
|
||||||
out[locus] = token ? [token, token] : base[locus]
|
if (!token) {
|
||||||
|
out[locus] = base[locus]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
|
||||||
|
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||||
|
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||||
|
out[locus] = [a, b]
|
||||||
}
|
}
|
||||||
return makeGenotype(out)
|
return makeGenotype(out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,303 +154,228 @@
|
|||||||
"sortOrder": 22,
|
"sortOrder": 22,
|
||||||
"image": "kohlfuchs.jpg"
|
"image": "kohlfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Marder",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
|
||||||
"sortOrder": 23,
|
|
||||||
"image": "marder.JPG"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Siam (Marder-Hell)",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
|
||||||
"sortOrder": 24,
|
|
||||||
"image": "siam-marder-hell.JPG"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs",
|
"name": "Polarfuchs",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 25,
|
"sortOrder": 23,
|
||||||
"image": "polarfuchs.jpg"
|
"image": "polarfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Saphir",
|
"name": "Saphir",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 26,
|
"sortOrder": 24,
|
||||||
"image": "saphir.jpg"
|
"image": "saphir.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Orangeschimmel",
|
"name": "Orangeschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
"sortOrder": 27,
|
"sortOrder": 25,
|
||||||
"image": "schimmel-orangeschimmel.jpg"
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas",
|
"name": "Topas",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 28,
|
"sortOrder": 26,
|
||||||
"image": "topas.jpg"
|
"image": "topas.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Platin-Hell",
|
"name": "Platin-Hell",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 29,
|
"sortOrder": 27,
|
||||||
"image": "platin-hell.jpg"
|
"image": "platin-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agouti dd",
|
"name": "Agouti dd",
|
||||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
"sortOrder": 30,
|
"sortOrder": 28,
|
||||||
"image": "agouti-dd.jpg"
|
"image": "agouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberagouti dd",
|
"name": "Silberagouti dd",
|
||||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 31,
|
"sortOrder": 29,
|
||||||
"image": "silberagouti-dd.jpg"
|
"image": "silberagouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs dd",
|
"name": "Kohlfuchs dd",
|
||||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
"sortOrder": 32,
|
"sortOrder": 30,
|
||||||
"image": "kohlfuchs-dd.jpg"
|
"image": "kohlfuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Anthrazit dd",
|
"name": "Anthrazit dd",
|
||||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 33,
|
"sortOrder": 31,
|
||||||
"image": "anthrazit-dd.jpg"
|
"image": "anthrazit-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Agouti CP-Hell",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
|
||||||
"sortOrder": 34,
|
|
||||||
"image": "agouti-cp-hell.JPG"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Blaufuchs CP",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD ee gg PP spsp rere",
|
|
||||||
"sortOrder": 35,
|
|
||||||
"image": "blaufuchs-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"name": "Silberschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
"sortOrder": 36,
|
"sortOrder": 32,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
"sortOrder": 37,
|
"sortOrder": 33,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
"sortOrder": 38,
|
"sortOrder": 34,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Polarfuchs-Hell CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
|
||||||
"sortOrder": 39,
|
|
||||||
"image": "polarfuchs-hell-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
"sortOrder": 40,
|
"sortOrder": 35,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||||
"sortOrder": 41,
|
"sortOrder": 36,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs, hell",
|
"name": "Kohlfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 42,
|
"sortOrder": 37,
|
||||||
"image": "kohlfuchs-hell.jpg"
|
"image": "kohlfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs, hell",
|
"name": "Goldfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 43,
|
"sortOrder": 38,
|
||||||
"image": "goldfuchs-hell.jpg"
|
"image": "goldfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||||
"sortOrder": 44,
|
"sortOrder": 39,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold-Hell",
|
"name": "Gold-Hell",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 45,
|
"sortOrder": 40,
|
||||||
"image": "gold-hell.jpg"
|
"image": "gold-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Siam (Marder-Hell) dd",
|
|
||||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
|
||||||
"sortOrder": 46,
|
|
||||||
"image": "siam-marder-hell-dd.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Marder dd",
|
|
||||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
|
||||||
"sortOrder": 47,
|
|
||||||
"image": "marder-dd.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Zobel-Hell",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
|
||||||
"sortOrder": 48,
|
|
||||||
"image": "zobel-hell.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Silberagouti dd CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
|
||||||
"sortOrder": 49,
|
|
||||||
"image": "silberagouti-dd-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Silberagouti-Hell dd CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
|
||||||
"sortOrder": 50,
|
|
||||||
"image": "silberagouti-hell-dd-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Agouti dd CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
|
||||||
"sortOrder": 51,
|
|
||||||
"image": "agouti-dd-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Agouti-Hell dd CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
|
||||||
"sortOrder": 52,
|
|
||||||
"image": "agouti-hell-dd-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs, hell",
|
"name": "Blaufuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 53,
|
"sortOrder": 41,
|
||||||
"image": "blaufuchs-hell.jpeg"
|
"image": "blaufuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||||
"sortOrder": 54,
|
"sortOrder": 42,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs, hell",
|
"name": "Polarfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 55,
|
"sortOrder": 43,
|
||||||
"image": "polarfuchs-hell.jpeg"
|
"image": "polarfuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
"sortOrder": 56,
|
"sortOrder": 44,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs, hell",
|
"name": "Rotfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 57,
|
"sortOrder": 45,
|
||||||
"image": "rotfuchs-hell.jpg"
|
"image": "rotfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Zobel dd",
|
|
||||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
|
||||||
"sortOrder": 58,
|
|
||||||
"image": "zobel-dd.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs-Hell",
|
"name": "Kohlfuchs-Hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 59,
|
"sortOrder": 46,
|
||||||
"image": "kohlfuchs-hell-2.jpg"
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Kohlfuchs CP",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
|
||||||
"sortOrder": 60,
|
|
||||||
"image": "kohlfuchs-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Algierfuchs CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
|
||||||
"sortOrder": 61,
|
|
||||||
"image": "algierfuchs-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Silberagouti CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
|
||||||
"sortOrder": 62,
|
|
||||||
"image": "silberagouti-cp.JPG"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Agouti CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
|
||||||
"sortOrder": 63,
|
|
||||||
"image": "agouti-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Algierfuchs-Hell CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
|
||||||
"sortOrder": 64,
|
|
||||||
"image": "algierfuchs-hell-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Kohlfuchs,hell CP",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
|
||||||
"sortOrder": 65,
|
|
||||||
"image": "kohlfuchs-hell-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Polarfuchs CP",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
|
||||||
"sortOrder": 66,
|
|
||||||
"image": "polarfuchs-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs, hell",
|
"name": "Algierfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 67,
|
"sortOrder": 47,
|
||||||
"image": "algierfuchs-hell.JPG"
|
"image": "algierfuchs-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas dd",
|
"name": "Topas dd",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 68,
|
"sortOrder": 48,
|
||||||
"image": "topas-dd.jpg"
|
"image": "topas-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Zobel-Hell dd",
|
|
||||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
|
||||||
"sortOrder": 69,
|
|
||||||
"image": "zobel-hell-dd.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Kohlfuchsschimmel CP",
|
|
||||||
"canonicalGenotype": "aa cchmcchm DD efef GG PP spsp rere",
|
|
||||||
"sortOrder": 70,
|
|
||||||
"image": "kohlfuchsschimmel-cp.JPG"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs dd",
|
"name": "Blaufuchs dd",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
"sortOrder": 71,
|
"sortOrder": 49,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Marder",
|
||||||
|
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 50,
|
||||||
|
"image": "marder.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Siam",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 51,
|
||||||
|
"image": "siam-marder-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zobel-Hell",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 52,
|
||||||
|
"image": "zobel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 53,
|
||||||
|
"image": "agouti-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 54,
|
||||||
|
"image": "silberagouti-cp.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 55,
|
||||||
|
"image": "algierfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 56,
|
||||||
|
"image": "polarfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Blaufuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 59
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"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": 72
|
"sortOrder": 60
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -330,6 +330,24 @@ textarea {
|
|||||||
color: #3a6ea5;
|
color: #3a6ea5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* BESTAND-FILTER: subtle marker for external (non-Bestand) animals. Inline next
|
||||||
|
to the name, so it should not be pushed to the grid edge like the status badge. */
|
||||||
|
.badge--external {
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #777;
|
||||||
|
border: 1px dashed #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
|
||||||
|
.field--check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.gerbil-card {
|
.gerbil-card {
|
||||||
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
||||||
@@ -741,4 +759,4 @@ textarea {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|||||||
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* INBOX-1: Anfrage-Detail — Nachricht lesen, Triage (Status / Verwerfen /
|
||||||
|
* Abnehmer zuordnen), KI-Antwortentwurf (INBOX-2) und Versand (INBOX-3).
|
||||||
|
*
|
||||||
|
* - Triage-PUT überschreibt assignedContactId immer → bei jedem Update wird
|
||||||
|
* die aktuelle Zuordnung mitgesendet (Backend-Vertrag, siehe api/requests.ts).
|
||||||
|
* - KI entwirft NUR (human-in-the-loop): Entwurf landet editierbar im
|
||||||
|
* Textfeld; gesendet wird ausschließlich nach Bestätigung.
|
||||||
|
* - 503 AiKeyMissing / MailNotConfigured / MailAuthFailed → deutsche Hinweise
|
||||||
|
* (gleiches Muster wie die Verkaufstext-KI).
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { errorCode } from '../api/client'
|
||||||
|
import {
|
||||||
|
REQUEST_STATUSES,
|
||||||
|
draftReply,
|
||||||
|
getRequest,
|
||||||
|
problemTitle,
|
||||||
|
sendReply,
|
||||||
|
triageRequest,
|
||||||
|
type InboxRequest,
|
||||||
|
type RequestStatus,
|
||||||
|
} from '../api/requests'
|
||||||
|
import { listContactsPaged } from '../api/contacts'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { StatusBadge } from './AnfragenPage'
|
||||||
|
import { formatDateTime } from '../format/labels'
|
||||||
|
import './anfragen.css'
|
||||||
|
|
||||||
|
export default function AnfrageDetailPage() {
|
||||||
|
const t = de.pages.anfragen
|
||||||
|
const td = t.detail
|
||||||
|
const { id = '' } = useParams()
|
||||||
|
|
||||||
|
const request = useApi(() => getRequest(id), [id])
|
||||||
|
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||||
|
|
||||||
|
// Antwort-Text: lokaler Entwurf; aus draftReply vorbefüllt, sobald geladen.
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [replyInitFor, setReplyInitFor] = useState<string | null>(null)
|
||||||
|
if (request.data && replyInitFor !== request.data.id) {
|
||||||
|
setReplyInitFor(request.data.id)
|
||||||
|
setReply(request.data.draftReply ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [notice, setNotice] = useState<string | null>(null)
|
||||||
|
const [hint, setHint] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/** Aktuellen Server-Stand nach einer Mutation übernehmen (ohne Neu-Laden). */
|
||||||
|
const [override, setOverride] = useState<InboxRequest | null>(null)
|
||||||
|
const current = override && override.id === id ? override : request.data
|
||||||
|
|
||||||
|
const triage = useMutation((next: { status?: RequestStatus; assignedContactId: string | null }) =>
|
||||||
|
triageRequest(id, next),
|
||||||
|
)
|
||||||
|
async function applyTriage(next: { status?: RequestStatus; assignedContactId: string | null }) {
|
||||||
|
setNotice(null)
|
||||||
|
const result = await triage.run(next)
|
||||||
|
if (result.ok && current) {
|
||||||
|
setOverride({
|
||||||
|
...current,
|
||||||
|
status: next.status ?? current.status,
|
||||||
|
assignedContactId: next.assignedContactId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = useMutation(() => draftReply(id))
|
||||||
|
async function onDraft() {
|
||||||
|
setHint(null)
|
||||||
|
setNotice(null)
|
||||||
|
const result = await draft.run()
|
||||||
|
if (result.ok) {
|
||||||
|
setOverride(result.value)
|
||||||
|
setReply(result.value.draftReply ?? '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const code = errorCode(result.cause)
|
||||||
|
if (code === 'AiKeyMissing') setHint(td.aiKeyMissing)
|
||||||
|
else if (code === 'AiUpstreamError') setHint(td.aiUpstreamError)
|
||||||
|
// sonst: draft.error treibt den Alert
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = useMutation(() => sendReply(id, reply))
|
||||||
|
async function onSend() {
|
||||||
|
setHint(null)
|
||||||
|
setNotice(null)
|
||||||
|
if (reply.trim() === '') {
|
||||||
|
setHint(td.sendEmptyBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!window.confirm(td.sendConfirm)) return
|
||||||
|
const result = await send.run()
|
||||||
|
if (result.ok) {
|
||||||
|
setOverride(result.value)
|
||||||
|
setNotice(td.sent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const title = problemTitle(result.cause)
|
||||||
|
if (title === 'MailNotConfigured') setHint(t.mailNotConfigured)
|
||||||
|
else if (title === 'MailAuthFailed') setHint(t.mailAuthFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
if (request.error || !current) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<p className="muted">{request.error ?? td.notFound}</p>
|
||||||
|
<Link to="/anfragen" className="btn">
|
||||||
|
{td.back}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = current
|
||||||
|
const contactItems = contacts.data?.items ?? []
|
||||||
|
const mutationError = triage.error ?? draft.error ?? send.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page anfrage-detail">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{r.subject ?? td.noSubject}</h2>
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
</div>
|
||||||
|
<div className="head-actions">
|
||||||
|
<Link to="/anfragen" className="btn">
|
||||||
|
{td.back}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl className="def-list">
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{td.from}</dt>
|
||||||
|
<dd>{r.fromName ? `${r.fromName} <${r.fromAddress}>` : r.fromAddress}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{td.receivedAt}</dt>
|
||||||
|
<dd>{formatDateTime(r.receivedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
{r.answeredAt && (
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{td.answeredAt}</dt>
|
||||||
|
<dd>{formatDateTime(r.answeredAt)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>{td.message}</h3>
|
||||||
|
<div className="anfrage-body">{r.bodyText?.trim() ? r.bodyText : td.noBody}</div>
|
||||||
|
|
||||||
|
<h3>{td.triage}</h3>
|
||||||
|
{mutationError && <div className="alert alert--error">{mutationError}</div>}
|
||||||
|
<div className="anfrage-triage">
|
||||||
|
<label className="field">
|
||||||
|
<span>{td.statusLabel}</span>
|
||||||
|
<select
|
||||||
|
value={r.status}
|
||||||
|
disabled={triage.pending}
|
||||||
|
onChange={(e) =>
|
||||||
|
applyTriage({
|
||||||
|
status: e.target.value as RequestStatus,
|
||||||
|
assignedContactId: r.assignedContactId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{REQUEST_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t.statusLabels[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{td.assignContact}</span>
|
||||||
|
<select
|
||||||
|
value={r.assignedContactId ?? ''}
|
||||||
|
disabled={triage.pending || contacts.loading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const contactId = e.target.value || null
|
||||||
|
// Zuordnung setzt den Status auf „Zugeordnet“ (Beantwortet bleibt).
|
||||||
|
applyTriage({
|
||||||
|
status:
|
||||||
|
contactId && r.status !== 'Answered' ? 'Assigned' : undefined,
|
||||||
|
assignedContactId: contactId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{td.assignNone}</option>
|
||||||
|
{contactItems.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{r.status !== 'Abandoned' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger anfrage-abandon"
|
||||||
|
disabled={triage.pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm(td.abandonConfirm)) return
|
||||||
|
applyTriage({ status: 'Abandoned', assignedContactId: r.assignedContactId })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{td.abandon}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{td.reply}</h3>
|
||||||
|
{notice && <div className="alert">{notice}</div>}
|
||||||
|
{hint && <div className="alert">{hint}</div>}
|
||||||
|
<div className="form anfrage-reply">
|
||||||
|
<textarea
|
||||||
|
className="anfrage-reply__text"
|
||||||
|
placeholder={td.replyPlaceholder}
|
||||||
|
value={reply}
|
||||||
|
onChange={(e) => setReply(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="muted anfrage-reply__hint">{td.draftHint}</p>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={onDraft} disabled={draft.pending}>
|
||||||
|
{draft.pending ? td.drafting : td.draftButton}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={send.pending}
|
||||||
|
>
|
||||||
|
{send.pending ? td.sending : td.send}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
150
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
150
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* INBOX-1: Anfragen-Posteingang — Liste mit Status-Filter (Gridify) und
|
||||||
|
* manuellem Gmail-Abruf („Anfragen abrufen“, POST /api/requests/sync).
|
||||||
|
* Solange Gmail unkonfiguriert ist, antwortet der Sync mit MailNotConfigured
|
||||||
|
* → freundlicher deutscher Hinweis statt Fehler.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import {
|
||||||
|
REQUEST_STATUSES,
|
||||||
|
listRequests,
|
||||||
|
syncRequests,
|
||||||
|
type RequestStatus,
|
||||||
|
} from '../api/requests'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { formatDateTime } from '../format/labels'
|
||||||
|
import './anfragen.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: RequestStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge anfrage-badge anfrage-badge--${status.toLowerCase()}`}>
|
||||||
|
{de.pages.anfragen.statusLabels[status]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnfragenPage() {
|
||||||
|
const t = de.pages.anfragen
|
||||||
|
const [status, setStatus] = useState<RequestStatus | ''>('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [notice, setNotice] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const requests = useApi(
|
||||||
|
() =>
|
||||||
|
listRequests({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
orderBy: 'receivedAt desc',
|
||||||
|
filter: status === '' ? undefined : `status==${status}`,
|
||||||
|
}),
|
||||||
|
[page, status],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sync = useMutation(() => syncRequests())
|
||||||
|
async function onSync() {
|
||||||
|
setNotice(null)
|
||||||
|
const result = await sync.run()
|
||||||
|
if (!result.ok) return // sync.error treibt den Alert
|
||||||
|
if (result.value.error === 'MailNotConfigured') setNotice(t.mailNotConfigured)
|
||||||
|
else if (result.value.error === 'MailAuthFailed') setNotice(t.mailAuthFailed)
|
||||||
|
else {
|
||||||
|
setNotice(t.syncImported(result.value.imported))
|
||||||
|
if (result.value.imported > 0) requests.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="head-actions">
|
||||||
|
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
||||||
|
{sync.pending ? t.syncing : t.sync}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice && <div className="alert">{notice}</div>}
|
||||||
|
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||||
|
|
||||||
|
{/* Status-Filter */}
|
||||||
|
<div className="filters">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.detail.statusLabel}</span>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value as RequestStatus | '')
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t.filterAll}</option>
|
||||||
|
{REQUEST_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t.statusLabels[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{requests.error && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{requests.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={requests.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requests.data && requests.data.items.length === 0 && (
|
||||||
|
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requests.data && requests.data.items.length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{requests.data.items.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
||||||
|
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
<span className="anfrage-card__subject">
|
||||||
|
{r.subject ?? de.pages.anfragen.detail.noSubject}
|
||||||
|
</span>
|
||||||
|
<span className="gerbil-card__meta">{formatDateTime(r.receivedAt)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requests.data && requests.data.totalCount > PAGE_SIZE && (
|
||||||
|
<nav className="pager" aria-label="Seitennavigation">
|
||||||
|
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
{de.common.previous}
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{de.common.page} {page} {de.common.of}{' '}
|
||||||
|
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
{de.common.next}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -104,6 +104,11 @@ export default function GerbilDetailPage() {
|
|||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
<GerbilProfilePhoto gerbilId={g.id} />
|
||||||
<h2>{g.name}</h2>
|
<h2>{g.name}</h2>
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||||
|
{g.isResident === false && (
|
||||||
|
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||||
|
{de.pages.gerbils.externalBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface FormState {
|
|||||||
receiverContactId: string
|
receiverContactId: string
|
||||||
genotype: string
|
genotype: string
|
||||||
notes: string
|
notes: string
|
||||||
|
isResident: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
|
|||||||
receiverContactId: '',
|
receiverContactId: '',
|
||||||
genotype: '',
|
genotype: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
isResident: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
|
|||||||
receiverContactId: string | null
|
receiverContactId: string | null
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
isResident?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
|
|||||||
receiverContactId: g.receiverContactId ?? '',
|
receiverContactId: g.receiverContactId ?? '',
|
||||||
genotype: g.genotype ?? '',
|
genotype: g.genotype ?? '',
|
||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
|
isResident: g.isResident ?? true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
|
|||||||
receiverContactId: nn(form.receiverContactId),
|
receiverContactId: nn(form.receiverContactId),
|
||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
|
isResident: form.isResident,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
|
<span>{t.form.isResidentLabel}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isResident}
|
||||||
|
onChange={(e) => set('isResident', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export default function GerbilsPage() {
|
|||||||
const [gender, setGender] = useState<Gender | ''>('')
|
const [gender, setGender] = useState<Gender | ''>('')
|
||||||
const [colorVarietyId, setColorVarietyId] = useState('')
|
const [colorVarietyId, setColorVarietyId] = useState('')
|
||||||
const [originBreeder, setOriginBreeder] = useState('')
|
const [originBreeder, setOriginBreeder] = useState('')
|
||||||
|
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
|
||||||
|
const [showExternal, setShowExternal] = useState(false)
|
||||||
const [sort, setSort] = useState<SortKey>('nameAsc')
|
const [sort, setSort] = useState<SortKey>('nameAsc')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
@@ -50,6 +52,9 @@ export default function GerbilsPage() {
|
|||||||
gender && condition({ field: 'gender', op: '==', value: gender }),
|
gender && condition({ field: 'gender', op: '==', value: gender }),
|
||||||
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
||||||
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
|
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
|
||||||
|
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
|
||||||
|
// restrict to resident animals (server-side, so paging/counts stay correct).
|
||||||
|
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
|
||||||
)
|
)
|
||||||
const orderBy = SORT_ORDER_BY[sort]
|
const orderBy = SORT_ORDER_BY[sort]
|
||||||
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
||||||
@@ -62,6 +67,7 @@ export default function GerbilsPage() {
|
|||||||
setGender('')
|
setGender('')
|
||||||
setColorVarietyId('')
|
setColorVarietyId('')
|
||||||
setOriginBreeder('')
|
setOriginBreeder('')
|
||||||
|
setShowExternal(false)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setSort('nameAsc')
|
setSort('nameAsc')
|
||||||
}
|
}
|
||||||
@@ -186,6 +192,14 @@ export default function GerbilsPage() {
|
|||||||
<option value="birthAsc">{t.sort.birthAsc}</option>
|
<option value="birthAsc">{t.sort.birthAsc}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field field--check" title={t.filters.showExternalHint}>
|
||||||
|
<span>{t.filters.showExternal}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showExternal}
|
||||||
|
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button type="button" className="btn" onClick={resetFilters}>
|
<button type="button" className="btn" onClick={resetFilters}>
|
||||||
{t.filters.reset}
|
{t.filters.reset}
|
||||||
</button>
|
</button>
|
||||||
@@ -234,7 +248,14 @@ export default function GerbilsPage() {
|
|||||||
aria-label={g.name}
|
aria-label={g.name}
|
||||||
/>
|
/>
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{g.name}</span>
|
<span className="gerbil-card__name">
|
||||||
|
{g.name}
|
||||||
|
{g.isResident === false && (
|
||||||
|
<span className="badge badge--external" title={t.externalTitle}>
|
||||||
|
{t.externalBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>
|
||||||
{statusLabel(g.status)}
|
{statusLabel(g.status)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
439
gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Normal file
439
gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* WEB-0b: Seiten-Editor der öffentlichen Webseite.
|
||||||
|
* - Kopf: Titel, SEO-Beschreibung, Status (Entwurf/Veröffentlicht).
|
||||||
|
* - Geordneter Block-Editor: Blöcke hinzufügen, bearbeiten, verschieben, löschen.
|
||||||
|
*
|
||||||
|
* Reihenfolge/Anlegen/Löschen rufen das Backend auf und laden danach neu; der
|
||||||
|
* Editor wird über `key` neu montiert, sodass der lokale Zustand frisch aus den
|
||||||
|
* geladenen Daten initialisiert wird (keine Prop→State-Synchronisation per Effekt).
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import {
|
||||||
|
ADDABLE_BLOCK_TYPES,
|
||||||
|
addBlock,
|
||||||
|
defaultBlockData,
|
||||||
|
deleteBlock,
|
||||||
|
getPage,
|
||||||
|
reorderBlocks,
|
||||||
|
updateBlock,
|
||||||
|
updatePage,
|
||||||
|
type Block,
|
||||||
|
type BlockData,
|
||||||
|
type BlockType,
|
||||||
|
type GalleryImage,
|
||||||
|
type Page,
|
||||||
|
type PageStatus,
|
||||||
|
} from '../api/pages'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import './webseite.css'
|
||||||
|
|
||||||
|
const asStr = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||||
|
|
||||||
|
export default function WebseiteEditorPage() {
|
||||||
|
const { slug = '' } = useParams<{ slug: string }>()
|
||||||
|
const page = useApi(() => getPage(slug), [slug])
|
||||||
|
|
||||||
|
if (page.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
if (page.error || !page.data) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<h2>{de.pages.webseite.title}</h2>
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{page.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={page.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signatur der Blöcke -> erzwingt Neu-Montage nach Reload (frische Init).
|
||||||
|
const signature = page.data.blocks.map((b) => `${b.id}:${b.order}`).join('|')
|
||||||
|
return <PageEditor key={signature} page={page.data} reload={page.reload} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditor({ page, reload }: { page: Page; reload: () => void }) {
|
||||||
|
const t = de.pages.webseite
|
||||||
|
const [title, setTitle] = useState(page.title)
|
||||||
|
const [seo, setSeo] = useState(page.seoDescription ?? '')
|
||||||
|
const [status, setStatus] = useState<PageStatus>(page.status)
|
||||||
|
const [savedHead, setSavedHead] = useState(false)
|
||||||
|
|
||||||
|
const saveHead = useMutation(() =>
|
||||||
|
updatePage(page.id, {
|
||||||
|
slug: page.slug,
|
||||||
|
title,
|
||||||
|
seoDescription: seo.trim() === '' ? null : seo,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async function onSaveHead() {
|
||||||
|
const r = await saveHead.run()
|
||||||
|
if (r.ok) {
|
||||||
|
setSavedHead(true)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [...page.blocks].sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
const adder = useMutation((type: BlockType) =>
|
||||||
|
addBlock(page.id, { type, data: defaultBlockData(type) }),
|
||||||
|
)
|
||||||
|
async function onAdd(type: BlockType) {
|
||||||
|
const r = await adder.run(type)
|
||||||
|
if (r.ok) reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remover = useMutation((id: string) => deleteBlock(id))
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm(t.blocks.confirmDelete)) return
|
||||||
|
const r = await remover.run(id)
|
||||||
|
if (r.ok) reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorder = useMutation((ids: string[]) => reorderBlocks(page.id, ids))
|
||||||
|
async function onMove(index: number, dir: -1 | 1) {
|
||||||
|
const target = index + dir
|
||||||
|
if (target < 0 || target >= blocks.length) return
|
||||||
|
const ids = blocks.map((b) => b.id)
|
||||||
|
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||||
|
const r = await reorder.run(ids)
|
||||||
|
if (r.ok) reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = adder.pending || remover.pending || reorder.pending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{page.title}</h2>
|
||||||
|
<p className="muted">/{page.slug}</p>
|
||||||
|
</div>
|
||||||
|
<div className="head-actions">
|
||||||
|
<Link to="/webseite" className="btn">
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Seiten-Kopf bearbeiten */}
|
||||||
|
<div className="webseite-section">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{t.editor.pageTitleLabel}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value)
|
||||||
|
setSavedHead(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{t.editor.seoLabel}</span>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={2}
|
||||||
|
value={seo}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSeo(e.target.value)
|
||||||
|
setSavedHead(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="field-hint">{t.editor.seoHint}</span>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{t.statusLabel}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value as PageStatus)
|
||||||
|
setSavedHead(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="Draft">{t.statusDraft}</option>
|
||||||
|
<option value="Published">{t.statusPublished}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{saveHead.error && <div className="alert alert--error">{saveHead.error}</div>}
|
||||||
|
<div className="head-actions">
|
||||||
|
<button type="button" className="btn btn--primary" onClick={onSaveHead} disabled={saveHead.pending}>
|
||||||
|
{t.editor.save}
|
||||||
|
</button>
|
||||||
|
{savedHead && <span className="saved-hint">{t.editor.saved}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block-Editor */}
|
||||||
|
<h3 className="webseite-blocks-title">{t.blocks.sectionTitle}</h3>
|
||||||
|
{(reorder.error || remover.error || adder.error) && (
|
||||||
|
<div className="alert alert--error">{reorder.error ?? remover.error ?? adder.error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blocks.length === 0 ? (
|
||||||
|
<p className="muted">{t.blocks.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ol className="block-list">
|
||||||
|
{blocks.map((b, i) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
<BlockCard
|
||||||
|
block={b}
|
||||||
|
index={i}
|
||||||
|
count={blocks.length}
|
||||||
|
busy={busy}
|
||||||
|
onMove={onMove}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="block-add" role="group" aria-label={t.blocks.addLabel}>
|
||||||
|
<span className="field-label">{t.blocks.addLabel}</span>
|
||||||
|
{ADDABLE_BLOCK_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={adder.pending}
|
||||||
|
onClick={() => onAdd(type)}
|
||||||
|
>
|
||||||
|
+ {t.blocks.types[type]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockCard({
|
||||||
|
block,
|
||||||
|
index,
|
||||||
|
count,
|
||||||
|
busy,
|
||||||
|
onMove,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
block: Block
|
||||||
|
index: number
|
||||||
|
count: number
|
||||||
|
busy: boolean
|
||||||
|
onMove: (index: number, dir: -1 | 1) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const t = de.pages.webseite.blocks
|
||||||
|
const [data, setData] = useState<BlockData>(block.data)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
const save = useMutation(() => updateBlock(block.id, { type: block.type, data }))
|
||||||
|
async function onSave() {
|
||||||
|
const r = await save.run()
|
||||||
|
if (r.ok) setSaved(true)
|
||||||
|
}
|
||||||
|
const set = (patch: BlockData) => {
|
||||||
|
setData((d) => ({ ...d, ...patch }))
|
||||||
|
setSaved(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="block-card">
|
||||||
|
<div className="block-card__head">
|
||||||
|
<span className="block-card__type">{t.types[block.type]}</span>
|
||||||
|
<span className="block-card__tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--icon"
|
||||||
|
aria-label={t.moveUp}
|
||||||
|
disabled={busy || index === 0}
|
||||||
|
onClick={() => onMove(index, -1)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--icon"
|
||||||
|
aria-label={t.moveDown}
|
||||||
|
disabled={busy || index === count - 1}
|
||||||
|
onClick={() => onMove(index, 1)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDelete(block.id)}
|
||||||
|
>
|
||||||
|
{t.delete}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BlockFields type={block.type} data={data} set={set} />
|
||||||
|
|
||||||
|
{save.error && <div className="alert alert--error">{save.error}</div>}
|
||||||
|
<div className="head-actions">
|
||||||
|
<button type="button" className="btn btn--primary" onClick={onSave} disabled={save.pending}>
|
||||||
|
{t.save}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="saved-hint">{t.saved}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockFields({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
}: {
|
||||||
|
type: BlockType
|
||||||
|
data: BlockData
|
||||||
|
set: (patch: BlockData) => void
|
||||||
|
}) {
|
||||||
|
const f = de.pages.webseite.blocks.fields
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'Heading':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.headingText}</span>
|
||||||
|
<input className="input" value={asStr(data.text)} onChange={(e) => set({ text: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.headingLevel}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={data.level === 3 ? '3' : '2'}
|
||||||
|
onChange={(e) => set({ level: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<option value="2">{f.levelMain}</option>
|
||||||
|
<option value="3">{f.levelSub}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'RichText':
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.markdown}</span>
|
||||||
|
<textarea
|
||||||
|
className="input block-markdown"
|
||||||
|
rows={6}
|
||||||
|
value={asStr(data.markdown)}
|
||||||
|
onChange={(e) => set({ markdown: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="field-hint">{f.markdownHint}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'Image':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.imageUrl}</span>
|
||||||
|
<input className="input" value={asStr(data.url)} onChange={(e) => set({ url: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.imageAlt}</span>
|
||||||
|
<input className="input" value={asStr(data.alt)} onChange={(e) => set({ alt: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'Gallery':
|
||||||
|
return <GalleryFields data={data} set={set} />
|
||||||
|
|
||||||
|
case 'ContactInfo':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.contactName}</span>
|
||||||
|
<input className="input" value={asStr(data.name)} onChange={(e) => set({ name: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.contactEmail}</span>
|
||||||
|
<input className="input" value={asStr(data.email)} onChange={(e) => set({ email: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.contactPhone}</span>
|
||||||
|
<input className="input" value={asStr(data.phone)} onChange={(e) => set({ phone: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.contactAddress}</span>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={2}
|
||||||
|
value={asStr(data.address)}
|
||||||
|
onChange={(e) => set({ address: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'AbgabetiereList':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="field-hint">{f.abgabeAutoNote}</p>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{f.abgabeIntro}</span>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={3}
|
||||||
|
value={asStr(data.intro)}
|
||||||
|
onChange={(e) => set({ intro: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function GalleryFields({ data, set }: { data: BlockData; set: (patch: BlockData) => void }) {
|
||||||
|
const f = de.pages.webseite.blocks.fields
|
||||||
|
const images: GalleryImage[] = Array.isArray(data.images) ? (data.images as GalleryImage[]) : []
|
||||||
|
|
||||||
|
const update = (i: number, patch: Partial<GalleryImage>) =>
|
||||||
|
set({ images: images.map((img, j) => (j === i ? { ...img, ...patch } : img)) })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">{f.galleryImages}</span>
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div key={i} className="gallery-row">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={f.imageUrl}
|
||||||
|
value={asStr(img.url)}
|
||||||
|
onChange={(e) => update(i, { url: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={f.imageAlt}
|
||||||
|
value={asStr(img.alt)}
|
||||||
|
onChange={(e) => update(i, { alt: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
onClick={() => set({ images: images.filter((_, j) => j !== i) })}
|
||||||
|
>
|
||||||
|
{f.galleryRemove}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn" onClick={() => set({ images: [...images, { url: '', alt: '' }] })}>
|
||||||
|
+ {f.galleryAdd}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
gerbil-manager-web/src/pages/WebseitePage.tsx
Normal file
66
gerbil-manager-web/src/pages/WebseitePage.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/** WEB-0b: Übersicht der Webseiten-Seiten mit Status (Entwurf/Veröffentlicht). */
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { listPages, type PageStatus } from '../api/pages'
|
||||||
|
import { useApi } from '../hooks/useApi'
|
||||||
|
import './webseite.css'
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: PageStatus }) {
|
||||||
|
const t = de.pages.webseite
|
||||||
|
const published = status === 'Published'
|
||||||
|
return (
|
||||||
|
<span className={published ? 'status-badge status-badge--published' : 'status-badge status-badge--draft'}>
|
||||||
|
{published ? t.statusPublished : t.statusDraft}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebseitePage() {
|
||||||
|
const t = de.pages.webseite
|
||||||
|
const pages = useApi(() => listPages(), [])
|
||||||
|
|
||||||
|
if (pages.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
if (pages.error || !pages.data) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{pages.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={pages.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<p className="muted">{t.intro}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{pages.data.length === 0 ? (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="card-list">
|
||||||
|
{pages.data.map((p) => (
|
||||||
|
<li key={p.id} className="gerbil-card webseite-card">
|
||||||
|
<span className="gerbil-card__name">{p.title}</span>
|
||||||
|
<span className="gerbil-card__meta">/{p.slug}</span>
|
||||||
|
<span className="webseite-card__actions">
|
||||||
|
<StatusBadge status={p.status} />
|
||||||
|
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
|
||||||
|
{t.edit}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
gerbil-manager-web/src/pages/anfragen.css
Normal file
84
gerbil-manager-web/src/pages/anfragen.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/* INBOX-1: Anfragen-Posteingang — seitenspezifische Stile
|
||||||
|
(Standing-Rule-2-Muster: eigene Datei statt index.css). */
|
||||||
|
|
||||||
|
/* ── Listen-Karte ── */
|
||||||
|
|
||||||
|
.anfrage-card {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-card__subject {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status-Badges (eigene Farben je Status) ── */
|
||||||
|
|
||||||
|
.anfrage-badge--new {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-badge--inprogress {
|
||||||
|
background: #e7eef6;
|
||||||
|
color: #3a6ea5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-badge--assigned {
|
||||||
|
background: #efe6f6;
|
||||||
|
color: #7a4ea5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-badge--answered {
|
||||||
|
background: #e6f2e6;
|
||||||
|
color: #3a7a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-badge--abandoned {
|
||||||
|
background: #ececec;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail ── */
|
||||||
|
|
||||||
|
.anfrage-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
margin: 0.5rem 0 1.25rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-triage {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin: 0.5rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-triage .field {
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-abandon {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-reply {
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-reply__text {
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anfrage-reply__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
123
gerbil-manager-web/src/pages/webseite.css
Normal file
123
gerbil-manager-web/src/pages/webseite.css
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite. */
|
||||||
|
|
||||||
|
/* ── Seitenliste ── */
|
||||||
|
.webseite-card__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.status-badge--draft {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.status-badge--published {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editor: Kopf + Blöcke ── */
|
||||||
|
.webseite-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-hint {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webseite-blocks-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.block-card__type {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.block-card__tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--icon {
|
||||||
|
min-width: 2.25rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-markdown {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.gallery-row .input {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
@@ -26,11 +26,15 @@ export const de = {
|
|||||||
// FEAT-13 (Kelly): Verträge + Einstellungen
|
// FEAT-13 (Kelly): Verträge + Einstellungen
|
||||||
contracts: 'Verträge',
|
contracts: 'Verträge',
|
||||||
settings: 'Einstellungen',
|
settings: 'Einstellungen',
|
||||||
|
// INBOX-1 (Kelly): Anfragen-Posteingang
|
||||||
|
requests: 'Anfragen',
|
||||||
openMenu: 'Menü öffnen',
|
openMenu: 'Menü öffnen',
|
||||||
closeMenu: 'Menü schließen',
|
closeMenu: 'Menü schließen',
|
||||||
mainNavigation: 'Hauptnavigation',
|
mainNavigation: 'Hauptnavigation',
|
||||||
// HELP-1
|
// HELP-1
|
||||||
help: 'Hilfe',
|
help: 'Hilfe',
|
||||||
|
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
|
||||||
|
website: 'Webseite',
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
home: {
|
home: {
|
||||||
@@ -45,6 +49,9 @@ export const de = {
|
|||||||
empty: 'Keine Rennmäuse gefunden.',
|
empty: 'Keine Rennmäuse gefunden.',
|
||||||
countLabel: 'Tiere',
|
countLabel: 'Tiere',
|
||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
|
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
|
||||||
|
externalBadge: 'Extern',
|
||||||
|
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
|
||||||
// Filter-Beschriftungen
|
// Filter-Beschriftungen
|
||||||
filters: {
|
filters: {
|
||||||
title: 'Filter',
|
title: 'Filter',
|
||||||
@@ -54,6 +61,9 @@ export const de = {
|
|||||||
all: 'Alle',
|
all: 'Alle',
|
||||||
reset: 'Filter zurücksetzen',
|
reset: 'Filter zurücksetzen',
|
||||||
sortBy: 'Sortieren nach',
|
sortBy: 'Sortieren nach',
|
||||||
|
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
|
||||||
|
showExternal: 'Externe Ahnen einblenden',
|
||||||
|
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
|
||||||
},
|
},
|
||||||
// Sortier-Optionen
|
// Sortier-Optionen
|
||||||
sort: {
|
sort: {
|
||||||
@@ -106,6 +116,9 @@ export const de = {
|
|||||||
none: '— keine Angabe —',
|
none: '— keine Angabe —',
|
||||||
genotypeHint:
|
genotypeHint:
|
||||||
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||||
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
|
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||||
|
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -455,6 +468,61 @@ export const de = {
|
|||||||
saved: 'Gespeichert.',
|
saved: 'Gespeichert.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── INBOX-1 (Kelly): Anfragen-Posteingang ──
|
||||||
|
anfragen: {
|
||||||
|
title: 'Anfragen',
|
||||||
|
empty: 'Keine Anfragen — hole neue E-Mails mit „Anfragen abrufen“.',
|
||||||
|
emptyFiltered: 'Keine Anfragen mit diesem Status.',
|
||||||
|
countText: (n: number) => (n === 1 ? '1 Anfrage' : `${n} Anfragen`),
|
||||||
|
sync: 'Anfragen abrufen',
|
||||||
|
syncing: 'Abrufen …',
|
||||||
|
syncImported: (n: number) =>
|
||||||
|
n === 0 ? 'Keine neuen Anfragen.' : n === 1 ? '1 neue Anfrage abgerufen.' : `${n} neue Anfragen abgerufen.`,
|
||||||
|
mailNotConfigured:
|
||||||
|
'Gmail ist noch nicht eingerichtet — Adresse und App-Passwort folgen in den Einstellungen.',
|
||||||
|
mailAuthFailed:
|
||||||
|
'Gmail-Anmeldung fehlgeschlagen — bitte das App-Passwort neu eintragen.',
|
||||||
|
filterAll: 'Alle',
|
||||||
|
statusLabels: {
|
||||||
|
New: 'Neu',
|
||||||
|
InProgress: 'In Bearbeitung',
|
||||||
|
Assigned: 'Zugeordnet',
|
||||||
|
Answered: 'Beantwortet',
|
||||||
|
Abandoned: 'Verworfen',
|
||||||
|
},
|
||||||
|
// Detailansicht
|
||||||
|
detail: {
|
||||||
|
back: 'Zurück zur Liste',
|
||||||
|
notFound: 'Diese Anfrage wurde nicht gefunden.',
|
||||||
|
from: 'Von',
|
||||||
|
receivedAt: 'Eingegangen',
|
||||||
|
answeredAt: 'Beantwortet am',
|
||||||
|
message: 'Nachricht',
|
||||||
|
noBody: '(kein Text)',
|
||||||
|
noSubject: '(kein Betreff)',
|
||||||
|
// Triage
|
||||||
|
triage: 'Bearbeitung',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
abandon: 'Verwerfen',
|
||||||
|
abandonConfirm: 'Anfrage wirklich verwerfen?',
|
||||||
|
assignContact: 'Abnehmer zuordnen',
|
||||||
|
assignNone: '— kein Abnehmer —',
|
||||||
|
assignedTo: 'Zugeordneter Abnehmer',
|
||||||
|
// Antwort
|
||||||
|
reply: 'Antwort',
|
||||||
|
replyPlaceholder: 'Antwort schreiben oder mit KI entwerfen …',
|
||||||
|
draftButton: 'Antwort entwerfen',
|
||||||
|
drafting: 'Entwurf wird erstellt …',
|
||||||
|
draftHint: 'Die KI erstellt nur einen Entwurf — gesendet wird erst nach deiner Bestätigung.',
|
||||||
|
aiKeyMissing: 'KI-Schlüssel fehlt — der Entwurfs-Assistent wird mit dem Schlüssel aktiviert.',
|
||||||
|
aiUpstreamError: 'Der KI-Dienst hat gerade ein Problem — bitte später erneut versuchen.',
|
||||||
|
send: 'Antwort senden',
|
||||||
|
sending: 'Senden …',
|
||||||
|
sendConfirm: 'Antwort jetzt per E-Mail senden?',
|
||||||
|
sendEmptyBody: 'Bitte zuerst eine Antwort schreiben.',
|
||||||
|
sent: 'Antwort gesendet — die Anfrage ist als „Beantwortet“ markiert.',
|
||||||
|
},
|
||||||
|
},
|
||||||
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
|
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
|
||||||
kontakte: {
|
kontakte: {
|
||||||
title: 'Kontakte',
|
title: 'Kontakte',
|
||||||
@@ -576,6 +644,69 @@ export const de = {
|
|||||||
photoNote:
|
photoNote:
|
||||||
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
|
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
|
||||||
},
|
},
|
||||||
|
// ── WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite ──
|
||||||
|
webseite: {
|
||||||
|
title: 'Webseite',
|
||||||
|
intro:
|
||||||
|
'Hier pflegst du die Inhalte deiner öffentlichen Webseite. Änderungen werden erst beim Veröffentlichen online sichtbar.',
|
||||||
|
empty: 'Es sind noch keine Seiten angelegt.',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
statusDraft: 'Entwurf',
|
||||||
|
statusPublished: 'Veröffentlicht',
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
back: 'Zurück zur Übersicht',
|
||||||
|
// Seiten-Editor (Kopf)
|
||||||
|
editor: {
|
||||||
|
pageTitleLabel: 'Seitentitel',
|
||||||
|
seoLabel: 'Beschreibung für Suchmaschinen (SEO)',
|
||||||
|
seoHint: 'Kurzer Text (ca. 1–2 Sätze), der bei Google unter dem Titel erscheint.',
|
||||||
|
save: 'Seite speichern',
|
||||||
|
saved: 'Seite gespeichert.',
|
||||||
|
},
|
||||||
|
// Block-Editor
|
||||||
|
blocks: {
|
||||||
|
sectionTitle: 'Inhaltsblöcke',
|
||||||
|
empty: 'Diese Seite hat noch keine Inhaltsblöcke. Füge unten den ersten hinzu.',
|
||||||
|
addLabel: 'Block hinzufügen:',
|
||||||
|
moveUp: 'Nach oben',
|
||||||
|
moveDown: 'Nach unten',
|
||||||
|
delete: 'Block löschen',
|
||||||
|
confirmDelete: 'Diesen Block wirklich löschen?',
|
||||||
|
save: 'Block speichern',
|
||||||
|
saved: 'Gespeichert.',
|
||||||
|
// Block-Typ-Namen
|
||||||
|
types: {
|
||||||
|
Heading: 'Überschrift',
|
||||||
|
RichText: 'Textabschnitt',
|
||||||
|
Image: 'Bild',
|
||||||
|
Gallery: 'Bildergalerie',
|
||||||
|
ContactInfo: 'Kontaktangaben',
|
||||||
|
AbgabetiereList: 'Abgabetiere (automatisch)',
|
||||||
|
},
|
||||||
|
// Feld-Beschriftungen je Block-Typ
|
||||||
|
fields: {
|
||||||
|
headingText: 'Überschrift-Text',
|
||||||
|
headingLevel: 'Größe',
|
||||||
|
levelMain: 'Hauptüberschrift',
|
||||||
|
levelSub: 'Unterüberschrift',
|
||||||
|
markdown: 'Text',
|
||||||
|
markdownHint:
|
||||||
|
'Einfache Formatierung möglich: **fett**, *kursiv*, Listen mit „- “, Links als [Text](https://…).',
|
||||||
|
imageUrl: 'Bild-Adresse (URL)',
|
||||||
|
imageAlt: 'Bildbeschreibung (für Suchmaschinen & Barrierefreiheit)',
|
||||||
|
galleryImages: 'Bilder',
|
||||||
|
galleryAdd: 'Bild hinzufügen',
|
||||||
|
galleryRemove: 'Entfernen',
|
||||||
|
contactName: 'Name',
|
||||||
|
contactEmail: 'E-Mail',
|
||||||
|
contactPhone: 'Telefon',
|
||||||
|
contactAddress: 'Adresse',
|
||||||
|
abgabeIntro: 'Einleitungstext',
|
||||||
|
abgabeAutoNote:
|
||||||
|
'Die Liste der Abgabetiere wird beim Veröffentlichen automatisch aus deinen aktuellen Abgabetieren erzeugt. Du kannst nur den Einleitungstext bearbeiten.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── HELP-1: In-App-Anleitung ──
|
// ── HELP-1: In-App-Anleitung ──
|
||||||
hilfe: {
|
hilfe: {
|
||||||
|
|||||||
89
tools/import/conflict-decisions.json
Normal file
89
tools/import/conflict-decisions.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||||
|
"resolutions": [
|
||||||
|
{
|
||||||
|
"name": "Firefly von den Kleinen Chaoten",
|
||||||
|
"dob": "18.12.2019",
|
||||||
|
"decision": "D-locus = D- (the DD in one source was a typo)",
|
||||||
|
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WildFire von den Kleinen Chaoten",
|
||||||
|
"dob": "05.10.2017",
|
||||||
|
"decision": "P-locus = PP (not P-)",
|
||||||
|
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Flint von den Kleinen Chaoten",
|
||||||
|
"dob": "23.12.2017",
|
||||||
|
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
|
||||||
|
"dateOfDeath": "10.05.2021",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Molly of Black Forest",
|
||||||
|
"dob": "13.09.2021",
|
||||||
|
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
|
||||||
|
"dateOfDeath": "03.05.2022",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Daja of Little Rose",
|
||||||
|
"dob": "16.05.2021",
|
||||||
|
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
|
||||||
|
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ichika von den Kleinen Chaoten",
|
||||||
|
"dob": "19.04.2020",
|
||||||
|
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
|
||||||
|
"genotype": "aa CC D- ee[f] Gg pp spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zuleika von den Kleinen Chaoten",
|
||||||
|
"dob": "24.10.2015",
|
||||||
|
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
|
||||||
|
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Milka of LennyLengo",
|
||||||
|
"dob": "09.12.2018",
|
||||||
|
"decision": "C-locus = Cc[h], E-locus = EE",
|
||||||
|
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silvain von den Kleinen Chaoten",
|
||||||
|
"dob": "27.03.2022",
|
||||||
|
"decision": "E-locus = ee, P-locus = Pp",
|
||||||
|
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Chelsea von den Kleinen Chaoten",
|
||||||
|
"dob": "15.10.2021",
|
||||||
|
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
|
||||||
|
"correctDob": "02.04.2021",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vestra von den Schlossmäusen",
|
||||||
|
"dob": "08.02.2019",
|
||||||
|
"decision": "D-locus = DD",
|
||||||
|
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Victoria Welby gen. Welby von den Kleinen Chaoten",
|
||||||
|
"dob": "16.01.2023",
|
||||||
|
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent.",
|
||||||
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -211,6 +211,11 @@ def extract_stammbaum(path):
|
|||||||
farbschlag = ""
|
farbschlag = ""
|
||||||
geno = geno0
|
geno = geno0
|
||||||
breeder = ""
|
breeder = ""
|
||||||
|
# BAND-AWARE (Julian-confirmed): early bands (gen 0-1, cols B/E/H) are 5-cell blocks
|
||||||
|
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
|
||||||
|
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
|
||||||
|
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
|
||||||
|
deep_band = gen_of(c) >= 2
|
||||||
for rr in range(r + 1, r + 4):
|
for rr in range(r + 1, r + 4):
|
||||||
cell = cells.get((c, rr))
|
cell = cells.get((c, rr))
|
||||||
if not cell:
|
if not cell:
|
||||||
@@ -221,7 +226,7 @@ def extract_stammbaum(path):
|
|||||||
elif re.search(r"\b(Zucht|Privatzucht)\b", cell) or cell.startswith("("):
|
elif re.search(r"\b(Zucht|Privatzucht)\b", cell) or cell.startswith("("):
|
||||||
breeder = cell
|
breeder = cell
|
||||||
used.add((c, rr))
|
used.add((c, rr))
|
||||||
elif not farbschlag and not re.match(r"^\*?\s?\d", cell) \
|
elif not deep_band and not farbschlag and not re.match(r"^\*?\s?\d", cell) \
|
||||||
and not looks_like_animal_name(cell):
|
and not looks_like_animal_name(cell):
|
||||||
farbschlag = cell
|
farbschlag = cell
|
||||||
used.add((c, rr))
|
used.add((c, rr))
|
||||||
@@ -829,6 +834,43 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------ main
|
# ------------------------------------------------------------------------ main
|
||||||
|
def apply_conflict_decisions(merged, conflicts, path):
|
||||||
|
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
|
||||||
|
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
|
||||||
|
farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A
|
||||||
|
matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype`
|
||||||
|
(breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates
|
||||||
|
a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||||
|
decisions = {}
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for r in (json.load(fh).get("resolutions") or []):
|
||||||
|
decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return 0
|
||||||
|
if not decisions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
resolved = 0
|
||||||
|
for a in merged:
|
||||||
|
d = decisions.get((norm_name(a["name"]), norm_dob(a["dob"])))
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
a["resolvedByDecision"] = True
|
||||||
|
if d.get("genotype"):
|
||||||
|
a["genotype"] = gt.parse(d["genotype"])
|
||||||
|
if d.get("farbschlag"):
|
||||||
|
a["farbschlag"] = d["farbschlag"]
|
||||||
|
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||||
|
if d.get("dateOfDeath"): # D5 death-date resolutions
|
||||||
|
a["death"] = norm_dob(d["dateOfDeath"])
|
||||||
|
if a.get("conflict"):
|
||||||
|
a["conflict"] = False
|
||||||
|
conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]]
|
||||||
|
resolved += 1
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
@@ -863,6 +905,8 @@ def main():
|
|||||||
print(f"Wurfchronik: {len(litters)} Würfe")
|
print(f"Wurfchronik: {len(litters)} Würfe")
|
||||||
|
|
||||||
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
|
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
|
||||||
|
decisions_path = os.path.join(HERE, "conflict-decisions.json")
|
||||||
|
resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path)
|
||||||
match_stats = match_litters(merged, litters)
|
match_stats = match_litters(merged, litters)
|
||||||
photo_count = sum(len(a["photos"]) for a in merged)
|
photo_count = sum(len(a["photos"]) for a in merged)
|
||||||
|
|
||||||
@@ -879,7 +923,8 @@ def main():
|
|||||||
zucht_splits, match_stats)
|
zucht_splits, match_stats)
|
||||||
|
|
||||||
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
|
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
|
||||||
f"| Konflikte: {len(conflicts)} | Zucht-Splits: {len(zucht_splits)} "
|
f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} "
|
||||||
|
f"| Zucht-Splits: {len(zucht_splits)} "
|
||||||
f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
|
f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
|
||||||
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
|
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
|
||||||
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "
|
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
- Nach Zusammenführung (eindeutige Tiere): **622**
|
- Nach Zusammenführung (eindeutige Tiere): **622**
|
||||||
- davon mit Geburtsdatum: 327
|
- davon mit Geburtsdatum: 327
|
||||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||||
- Konflikte zur Klärung: **21**
|
- Konflikte zur Klärung: **19**
|
||||||
- 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**
|
||||||
@@ -27,9 +27,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 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 |
|
| 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 |
|
||||||
| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
||||||
| Firefly von den Kleinen Chaoten | 18.12.2019 | /+, Aa c[chm]c[chm] D- Ee Gg PP Spsp // Aa c[chm]c[chm] DD Ee Gg PP Spsp | — | 2024 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
|
|
||||||
| Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
| Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
||||||
| WildFire von den Kleinen Chaoten | 05.10.2017 | aa c[chm]c[chm] D- Ee gg P- spsp // aa c[chm]c[chm] D- Ee gg PP spsp | — | — | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
|
|
||||||
| Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
|
| Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
|
||||||
| Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
| Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
||||||
| 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 |
|
||||||
@@ -142,7 +140,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
|
|
||||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/+` | 8 | ? |
|
| `/+` | 7 | ? |
|
||||||
| `-g` | 2 | ? |
|
| `-g` | 2 | ? |
|
||||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||||
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
|
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
|
||||||
|
|||||||
116
tools/import/test_extract.py
Normal file
116
tools/import/test_extract.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Zero-dep tests for extract.py band-aware Farbschlag + name-bleed guard.
|
||||||
|
|
||||||
|
Run: python test_extract.py (exit 0 = all pass)
|
||||||
|
Covers (PEDIGREE-LINK / Julian-confirmed): deep pedigree bands (gen >= 2, cols K/N/Q...)
|
||||||
|
are Name/DOB/Genotype ONLY — no Farbschlag cell — so a stray health note or the next
|
||||||
|
block's name must NOT be captured as Farbschlag; early bands (gen 0-1) keep their real
|
||||||
|
Farbschlag. Plus the looks_like_animal_name guard (a parent name must not be a Farbschlag).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import extract as e
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
global failed
|
||||||
|
print(("ok: " if cond else "FAIL: ") + name)
|
||||||
|
if not cond:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(ref, text):
|
||||||
|
return f'<c r="{ref}" t="inlineStr"><is><t>{text}</t></is></c>'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_xlsx(path, cells):
|
||||||
|
"""cells: {(colLetter+row): text}. Build a minimal single-sheet xlsx (no styles)."""
|
||||||
|
rows = {}
|
||||||
|
for ref, text in cells.items():
|
||||||
|
r = int("".join(ch for ch in ref if ch.isdigit()))
|
||||||
|
rows.setdefault(r, []).append(_cell(ref, text))
|
||||||
|
body = "".join(f'<row r="{r}">{"".join(cs)}</row>' for r, cs in sorted(rows.items()))
|
||||||
|
sheet = ('<?xml version="1.0"?><worksheet xmlns="http://x"><sheetData>'
|
||||||
|
+ body + "</sheetData></worksheet>")
|
||||||
|
with zipfile.ZipFile(path, "w") as z:
|
||||||
|
z.writestr("xl/worksheets/sheet1.xml", sheet)
|
||||||
|
|
||||||
|
|
||||||
|
# --- band-aware Farbschlag ---
|
||||||
|
# col E = gen 0 (early, HAS Farbschlag); col K = col 11 = gen 2 (deep, NO Farbschlag).
|
||||||
|
tmp = os.path.join(tempfile.gettempdir(), "bandtest.xlsx")
|
||||||
|
_make_xlsx(tmp, {
|
||||||
|
# early band (E): Name / *DOB / Farbschlag / Genotype
|
||||||
|
"E10": "Chesnut",
|
||||||
|
"E11": "*13.11.2019",
|
||||||
|
"E12": "Kohlfuchsschimmel",
|
||||||
|
"E13": "aa CC DD ee GG PP spsp rere",
|
||||||
|
# deep band (K): Name / *DOB / Genotype / stray NOTE (must NOT become Farbschlag)
|
||||||
|
"K10": "DeepAnimal",
|
||||||
|
"K11": "*01.01.2020",
|
||||||
|
"K12": "aa CC DD EE GG PP spsp rere",
|
||||||
|
"K13": "DD-Tumor",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
animals = e.extract_stammbaum(tmp)
|
||||||
|
by_name = {a["name"]: a for a in animals}
|
||||||
|
check("early band keeps real Farbschlag",
|
||||||
|
by_name.get("Chesnut", {}).get("farbschlag") == "Kohlfuchsschimmel")
|
||||||
|
check("deep band has NO Farbschlag (note not grabbed)",
|
||||||
|
by_name.get("DeepAnimal", {}).get("farbschlag") == "")
|
||||||
|
check("deep-band animal still parsed (Name/DOB/Genotype)",
|
||||||
|
"DeepAnimal" in by_name and by_name["DeepAnimal"]["dob"].startswith("01.01"))
|
||||||
|
finally:
|
||||||
|
try: os.remove(tmp)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
gen = e.gen_of
|
||||||
|
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
|
||||||
|
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2)
|
||||||
|
|
||||||
|
# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
|
||||||
|
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")
|
||||||
|
import json as _json
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
{"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
|
||||||
|
"decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
|
||||||
|
"source": "test"},
|
||||||
|
{"name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
|
||||||
|
"decision": "Todesdatum 10.05.2021 (2022 war Tippfehler)", "dateOfDeath": "10.05.2021",
|
||||||
|
"source": "test"},
|
||||||
|
]}, open(dec_path, "w", encoding="utf-8"))
|
||||||
|
merged = [
|
||||||
|
{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
|
||||||
|
"conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}},
|
||||||
|
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
|
||||||
|
"conflict": True, "farbschlag": "", "death": "10.05.2022",
|
||||||
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"},
|
||||||
|
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017"}]
|
||||||
|
n = e.apply_conflict_decisions(merged, conflicts, dec_path)
|
||||||
|
check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False)
|
||||||
|
check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True)
|
||||||
|
check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"])
|
||||||
|
check("decision dateOfDeath is authoritative (D5)", merged[1]["death"] == "10.05.2021")
|
||||||
|
check("decision removes both entries from conflicts list", conflicts == [])
|
||||||
|
check("apply_conflict_decisions returns resolved count", n == 2)
|
||||||
|
check("missing decisions file tolerated (returns 0)",
|
||||||
|
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
||||||
|
try: os.remove(dec_path)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
# --- name-bleed guard (a parent name is not a Farbschlag) ---
|
||||||
|
check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten"))
|
||||||
|
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
|
||||||
|
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||||
|
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"\n{failed} test(s) FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nALL PASS")
|
||||||
Reference in New Issue
Block a user