Compare commits
14 Commits
5732398e60
...
feat/akte-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb39cb180 | |||
| b53b8cf2ff | |||
| e460cd5905 | |||
| b83e96f552 | |||
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 |
@@ -38,9 +38,9 @@ public class GerbilStatusTests
|
|||||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
|
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OlderThan7y_without_death_returns_Deceased()
|
public void OlderThan6y_without_death_returns_Deceased()
|
||||||
{
|
{
|
||||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||||
var status = GerbilStatusService.Derive(
|
var status = GerbilStatusService.Derive(
|
||||||
@@ -48,14 +48,14 @@ public class GerbilStatusTests
|
|||||||
Assert.Equal(GerbilStatus.Deceased, status);
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
|
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ExactlyMaxAge_is_not_Deceased()
|
public void ExactlyMaxAge_is_not_Deceased()
|
||||||
{
|
{
|
||||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
|
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
|
||||||
var status = GerbilStatusService.Derive(
|
var status = GerbilStatusService.Derive(
|
||||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||||
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
|
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
|
||||||
// (threshold = "older than" so >= means Deceased)
|
// (threshold = "older than" so >= means Deceased)
|
||||||
Assert.Equal(GerbilStatus.Deceased, status);
|
Assert.Equal(GerbilStatus.Deceased, status);
|
||||||
}
|
}
|
||||||
@@ -70,11 +70,11 @@ public class GerbilStatusTests
|
|||||||
Assert.Equal(GerbilStatus.Breeding, status);
|
Assert.Equal(GerbilStatus.Breeding, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
|
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
|
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
|
||||||
{
|
{
|
||||||
var dob = Today.AddYears(-8);
|
var dob = Today.AddYears(-7);
|
||||||
var status = GerbilStatusService.Derive(
|
var status = GerbilStatusService.Derive(
|
||||||
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||||
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||||
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
|
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
|
||||||
Assert.False(gerbil.IsResident);
|
Assert.False(gerbil.IsResident);
|
||||||
Assert.Equal("docx", gerbil.ImportSource);
|
Assert.Equal("docx", gerbil.ImportSource);
|
||||||
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
name,
|
name,
|
||||||
gender = "female",
|
gender = "female",
|
||||||
genotype = "aa CC DD ee GG PP spsp rere",
|
genotype = "aa CC DD ee GG PP spsp rere",
|
||||||
originBreeder = "Zucht der Kleinen Chaoten",
|
originBreeder = "Zucht der kleinen Chaoten",
|
||||||
isDeaf = false,
|
isDeaf = false,
|
||||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||||
characterNote = "Liebling der Familie",
|
characterNote = "Liebling der Familie",
|
||||||
@@ -67,7 +67,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||||
Assert.Equal("female", GetStr(json, "gender"));
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
@@ -95,7 +95,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||||
Assert.Equal("female", GetStr(json, "gender"));
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||||
@@ -123,7 +123,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
|
|
||||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||||
|
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||||
@@ -133,6 +134,10 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasOne<Gerbil>().WithMany()
|
e.HasOne<Gerbil>().WithMany()
|
||||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||||
|
|
||||||
|
modelBuilder.Entity<EnclosurePhoto>(e =>
|
||||||
|
e.HasOne<Enclosure>().WithMany()
|
||||||
|
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
|
||||||
|
|
||||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||||
modelBuilder.Entity<SaleContract>(e =>
|
modelBuilder.Entity<SaleContract>(e =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
|
|||||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||||
|
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
|
||||||
|
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
|
||||||
public sealed record ContractAnimal(
|
public sealed record ContractAnimal(
|
||||||
string Name,
|
string Name,
|
||||||
string Geschlecht,
|
string Geschlecht,
|
||||||
DateOnly? Geburtsdatum = null,
|
DateOnly? Geburtsdatum = null,
|
||||||
string? Farbschlag = null);
|
string? Farbschlag = null,
|
||||||
|
byte[]? Photo = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||||
|
|||||||
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using QuestPDF.Fluent;
|
||||||
|
using QuestPDF.Helpers;
|
||||||
|
using QuestPDF.Infrastructure;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
|
||||||
|
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
|
||||||
|
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
|
||||||
|
/// 4–8 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
|
||||||
|
/// </summary>
|
||||||
|
public static class ContractPdfGenerator
|
||||||
|
{
|
||||||
|
static ContractPdfGenerator()
|
||||||
|
{
|
||||||
|
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
|
||||||
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
private static string Price(decimal p) => p.ToString("N2", German) + " €";
|
||||||
|
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
|
||||||
|
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
|
||||||
|
|
||||||
|
private const string Accent = "#A85F2E";
|
||||||
|
private const string Ink = "#3D2E23";
|
||||||
|
private const string Line = "#D9CCB4";
|
||||||
|
|
||||||
|
public static byte[] Generate(ContractData data)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(data);
|
||||||
|
|
||||||
|
return Document.Create(doc =>
|
||||||
|
{
|
||||||
|
doc.Page(page =>
|
||||||
|
{
|
||||||
|
page.Size(PageSizes.A4);
|
||||||
|
page.Margin(1.8f, Unit.Centimetre);
|
||||||
|
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
|
||||||
|
|
||||||
|
page.Header().PaddingBottom(8).Column(h =>
|
||||||
|
{
|
||||||
|
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
|
||||||
|
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
|
||||||
|
});
|
||||||
|
|
||||||
|
page.Content().PaddingTop(6).Column(col =>
|
||||||
|
{
|
||||||
|
col.Spacing(12);
|
||||||
|
Parties(col, data);
|
||||||
|
Animals(col, data);
|
||||||
|
Subject(col, data);
|
||||||
|
Clauses(col);
|
||||||
|
Signatures(col, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
page.Footer().AlignCenter().Text(t =>
|
||||||
|
{
|
||||||
|
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
|
||||||
|
t.Span("Seite ");
|
||||||
|
t.CurrentPageNumber();
|
||||||
|
t.Span(" / ");
|
||||||
|
t.TotalPages();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).GeneratePdf();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Heading(ColumnDescriptor col, string text) =>
|
||||||
|
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
|
||||||
|
|
||||||
|
private static void Kv(ColumnDescriptor col, string label, string value) =>
|
||||||
|
col.Item().Row(r =>
|
||||||
|
{
|
||||||
|
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
|
||||||
|
r.RelativeItem().Text(value).SemiBold();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void Bullet(ColumnDescriptor col, string text) =>
|
||||||
|
col.Item().Row(r =>
|
||||||
|
{
|
||||||
|
r.ConstantItem(14).Text("•").FontColor(Accent);
|
||||||
|
r.RelativeItem().Text(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void Parties(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
var s = data.Seller;
|
||||||
|
var b = data.Buyer;
|
||||||
|
Heading(col, "1. Vertragspartner");
|
||||||
|
col.Item().Text("Verkäufer / Züchter").SemiBold();
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(2);
|
||||||
|
Kv(c, "Zuchtname:", Or(s.ZuchtName));
|
||||||
|
Kv(c, "Vor- und Nachname:", Or(s.Name));
|
||||||
|
Kv(c, "Adresse:", Or(s.Address));
|
||||||
|
Kv(c, "Telefon:", Or(s.Phone, ""));
|
||||||
|
Kv(c, "E-Mail:", Or(s.Email, ""));
|
||||||
|
Kv(c, "Homepage:", Or(s.Homepage, ""));
|
||||||
|
});
|
||||||
|
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(2);
|
||||||
|
Kv(c, "Vor- und Nachname:", Or(b.Name));
|
||||||
|
Kv(c, "Adresse:", Or(b.Address));
|
||||||
|
Kv(c, "Telefon:", Or(b.Phone, ""));
|
||||||
|
Kv(c, "E-Mail:", Or(b.Email, ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Animals(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
Heading(col, "2. Angaben zu den Tieren");
|
||||||
|
col.Item().Column(list =>
|
||||||
|
{
|
||||||
|
list.Spacing(10);
|
||||||
|
foreach (var a in data.Animals)
|
||||||
|
{
|
||||||
|
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
|
||||||
|
// wie im Mustervertrag (das große Rechteck links).
|
||||||
|
list.Item().Border(1).BorderColor(Line).Row(row =>
|
||||||
|
{
|
||||||
|
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
|
||||||
|
.Padding(4).Element(box =>
|
||||||
|
{
|
||||||
|
if (a.Photo is { Length: > 0 } bytes)
|
||||||
|
box.AlignMiddle().Image(bytes).FitArea();
|
||||||
|
else
|
||||||
|
box.AlignMiddle().AlignCenter().Text("Kein Foto")
|
||||||
|
.FontSize(8).FontColor("#B8AC97");
|
||||||
|
});
|
||||||
|
|
||||||
|
row.RelativeItem(3f).Column(kv =>
|
||||||
|
{
|
||||||
|
AnimalRow(kv, "Name:", Or(a.Name, ""));
|
||||||
|
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
|
||||||
|
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
|
||||||
|
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
|
||||||
|
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
|
||||||
|
{
|
||||||
|
var cell = col.Item();
|
||||||
|
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
|
||||||
|
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
|
||||||
|
{
|
||||||
|
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
|
||||||
|
r.RelativeItem().Text(value).SemiBold();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Subject(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
Heading(col, "3. Gegenstand des Vertrages");
|
||||||
|
col.Item().Text(
|
||||||
|
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
|
||||||
|
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
|
||||||
|
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
|
||||||
|
$"von dem Züchter übergeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Clauses(ColumnDescriptor col)
|
||||||
|
{
|
||||||
|
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
|
||||||
|
col.Item().Text(
|
||||||
|
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
|
||||||
|
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
|
||||||
|
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
|
||||||
|
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
|
||||||
|
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
|
||||||
|
|
||||||
|
Heading(col, "5. Pflichten des Käufers");
|
||||||
|
col.Item().Text(
|
||||||
|
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
|
||||||
|
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(3);
|
||||||
|
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
|
||||||
|
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
|
||||||
|
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
|
||||||
|
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
|
||||||
|
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
|
||||||
|
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
|
||||||
|
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Heading(col, "6. Rechte des Käufers");
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(3);
|
||||||
|
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
|
||||||
|
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
|
||||||
|
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Heading(col, "7. Haftung / Zuwiderhandlung");
|
||||||
|
col.Item().Column(c =>
|
||||||
|
{
|
||||||
|
c.Spacing(3);
|
||||||
|
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
|
||||||
|
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
|
||||||
|
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
|
||||||
|
col.Item().Text(
|
||||||
|
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
|
||||||
|
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
|
||||||
|
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
|
||||||
|
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
|
||||||
|
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Signatures(ColumnDescriptor col, ContractData data)
|
||||||
|
{
|
||||||
|
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
|
||||||
|
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
|
||||||
|
col.Item().PaddingTop(28).Row(r =>
|
||||||
|
{
|
||||||
|
r.RelativeItem().Column(c =>
|
||||||
|
{
|
||||||
|
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
|
||||||
|
});
|
||||||
|
r.ConstantItem(40);
|
||||||
|
r.RelativeItem().Column(c =>
|
||||||
|
{
|
||||||
|
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? CauseOfDeath,
|
string? CauseOfDeath,
|
||||||
DateOnly? GoHomeDate,
|
DateOnly? GoHomeDate,
|
||||||
string? Genotype,
|
string? Genotype,
|
||||||
|
string? SpottingType,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
string? ImportSource,
|
string? ImportSource,
|
||||||
string? ExternalRef,
|
string? ExternalRef,
|
||||||
@@ -30,12 +31,13 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? CharacterNote,
|
string? CharacterNote,
|
||||||
bool? IsDeaf,
|
bool? IsDeaf,
|
||||||
bool IsResident,
|
bool IsResident,
|
||||||
string? ProfilePhotoUrl);
|
string? ProfilePhotoUrl,
|
||||||
|
bool IsCastrated);
|
||||||
|
|
||||||
public record LitterDto(
|
public record LitterDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
DateOnly Date,
|
DateOnly? Date,
|
||||||
int? TotalBorn,
|
int? TotalBorn,
|
||||||
int? DeathsWithin8Weeks,
|
int? DeathsWithin8Weeks,
|
||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
@@ -43,7 +45,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes);
|
||||||
|
|
||||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
|
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||||
|
|
||||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? CauseOfDeath,
|
string? CauseOfDeath,
|
||||||
DateOnly? GoHomeDate,
|
DateOnly? GoHomeDate,
|
||||||
string? Genotype,
|
string? Genotype,
|
||||||
|
string? SpottingType,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
string? ImportSource,
|
string? ImportSource,
|
||||||
string? ExternalRef,
|
string? ExternalRef,
|
||||||
@@ -81,11 +84,12 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
List<string>? CharacterTraits,
|
List<string>? CharacterTraits,
|
||||||
string? CharacterNote,
|
string? CharacterNote,
|
||||||
bool? IsDeaf,
|
bool? IsDeaf,
|
||||||
bool? IsResident);
|
bool? IsResident,
|
||||||
|
bool? IsCastrated);
|
||||||
|
|
||||||
public record LitterInput(
|
public record LitterInput(
|
||||||
string Name,
|
string Name,
|
||||||
DateOnly Date,
|
DateOnly? Date,
|
||||||
int? TotalBorn,
|
int? TotalBorn,
|
||||||
int? DeathsWithin8Weeks,
|
int? DeathsWithin8Weeks,
|
||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
@@ -93,7 +97,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes);
|
||||||
|
|
||||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
|
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||||
|
|
||||||
public record EnclosureInput(string Name, string? Notes);
|
public record EnclosureInput(string Name, string? Notes);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
List<Guid> GerbilIds,
|
List<Guid> GerbilIds,
|
||||||
decimal Price,
|
decimal Price,
|
||||||
DateOnly HandoverDate,
|
DateOnly HandoverDate,
|
||||||
DateOnly? ContractDate);
|
DateOnly? ContractDate,
|
||||||
|
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
|
||||||
|
Dictionary<Guid, Guid>? AnimalPhotos = null);
|
||||||
|
|
||||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||||
public record BreederProfileDto(
|
public record BreederProfileDto(
|
||||||
@@ -29,5 +31,6 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string Phone,
|
string Phone,
|
||||||
string Email,
|
string Email,
|
||||||
string Homepage,
|
string Homepage,
|
||||||
string City);
|
string City,
|
||||||
|
string NameSuffix);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
|
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||||
db.Contacts.Add(c);
|
db.Contacts.Add(c);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||||
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (c is null) return TypedResults.NotFound();
|
if (c is null) return TypedResults.NotFound();
|
||||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||||
|
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
|
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
{
|
{
|
||||||
private const string DocxContentType =
|
private const string DocxContentType =
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
private const string PdfContentType = "application/pdf";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
@@ -50,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var errors = new Dictionary<string, string[]>();
|
var errors = new Dictionary<string, string[]>();
|
||||||
|
|
||||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||||
|
var photoChoice = input.AnimalPhotos ?? [];
|
||||||
if (gerbilIds.Count == 0)
|
if (gerbilIds.Count == 0)
|
||||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||||
if (input.Price < 0)
|
if (input.Price < 0)
|
||||||
@@ -100,7 +102,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
ContractDate = contractDate,
|
ContractDate = contractDate,
|
||||||
FileName = fileName,
|
FileName = fileName,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
|
Animals = gerbilIds.Select(id => new SaleContractAnimal
|
||||||
|
{
|
||||||
|
GerbilId = id,
|
||||||
|
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
|
||||||
|
}).ToList(),
|
||||||
};
|
};
|
||||||
db.SaleContracts.Add(entity);
|
db.SaleContracts.Add(entity);
|
||||||
|
|
||||||
@@ -144,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
|
||||||
|
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var c = await db.SaleContracts.AsNoTracking()
|
||||||
|
.Include(x => x.Contact)
|
||||||
|
.Include(x => x.Animals)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (c is null || c.Contact is null) return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
|
||||||
|
var gerbils = await db.Gerbils.AsNoTracking()
|
||||||
|
.Include(g => g.ColorVariety)
|
||||||
|
.Where(g => gerbilIds.Contains(g.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
var settings = await db.BreederSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||||
|
?? new BreederSettings();
|
||||||
|
|
||||||
|
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
|
||||||
|
// existieren — sonst bleibt das Bild leer statt zu scheitern).
|
||||||
|
var photoByGerbil = c.Animals
|
||||||
|
.Where(a => a.PhotoId is not null)
|
||||||
|
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
|
||||||
|
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
|
||||||
|
|
||||||
|
var data = new ContractData(
|
||||||
|
Seller: ToSeller(settings),
|
||||||
|
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
|
||||||
|
Animals: gerbils
|
||||||
|
.Select(g => new ContractAnimal(
|
||||||
|
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
|
||||||
|
photoBytes.GetValueOrDefault(g.Id)))
|
||||||
|
.ToList(),
|
||||||
|
Price: c.Price,
|
||||||
|
HandoverDate: c.HandoverDate,
|
||||||
|
ContractDate: c.ContractDate);
|
||||||
|
|
||||||
|
var pdf = ContractPdfGenerator.Generate(data);
|
||||||
|
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
|
||||||
|
return TypedResults.File(pdf, PdfContentType, download);
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||||
@@ -166,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||||
|
|
||||||
|
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
|
||||||
|
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||||
|
|
||||||
|
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
|
||||||
|
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
|
||||||
|
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
|
||||||
|
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<Guid, byte[]>();
|
||||||
|
if (photoByGerbil.Count == 0) return result;
|
||||||
|
|
||||||
|
var photoIds = photoByGerbil.Values.ToList();
|
||||||
|
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||||
|
.Where(p => photoIds.Contains(p.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var (gerbilId, photoId) in photoByGerbil)
|
||||||
|
{
|
||||||
|
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
|
||||||
|
if (photo is null) continue;
|
||||||
|
var path = Path.Combine(photoRoot, photo.FileName);
|
||||||
|
if (File.Exists(path))
|
||||||
|
result[gerbilId] = await File.ReadAllBytesAsync(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||||
{
|
{
|
||||||
ZuchtName = s.ZuchtName,
|
ZuchtName = s.ZuchtName,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||||
g.Genotype = i.Genotype ?? g.Genotype;
|
g.Genotype = i.Genotype ?? g.Genotype;
|
||||||
|
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
||||||
g.Notes = i.Notes ?? g.Notes;
|
g.Notes = i.Notes ?? g.Notes;
|
||||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||||
@@ -128,13 +129,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
|
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
||||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
|
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
}).WithTags("Photos");
|
}).WithTags("Photos");
|
||||||
|
|
||||||
|
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
||||||
|
app.MapGet("/enclosures/{id:guid}/photos",
|
||||||
|
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||||
|
var photos = await db.EnclosurePhotos.AsNoTracking()
|
||||||
|
.Where(p => p.EnclosureId == id)
|
||||||
|
.OrderBy(p => p.SortOrder)
|
||||||
|
.ToListAsync();
|
||||||
|
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
||||||
|
}).WithTags("Photos");
|
||||||
|
|
||||||
|
app.MapPost("/enclosures/{id:guid}/photos",
|
||||||
|
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||||
|
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||||
|
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName);
|
||||||
|
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||||
|
var root = PhotoRoot(config, env);
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
|
||||||
|
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
||||||
|
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||||
|
|
||||||
|
var photo = new EnclosurePhoto
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EnclosureId = id,
|
||||||
|
FileName = fileName,
|
||||||
|
Caption = caption,
|
||||||
|
SortOrder = nextSort,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.EnclosurePhotos.Add(photo);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
||||||
|
}).WithTags("Photos").DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapDelete("/enclosure-photos/{id:guid}",
|
||||||
|
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
if (photo is null) return TypedResults.NotFound();
|
||||||
|
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
db.EnclosurePhotos.Remove(photo);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}).WithTags("Photos");
|
||||||
|
|
||||||
app.MapGet("/photos/files/{fileName}",
|
app.MapGet("/photos/files/{fileName}",
|
||||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
{
|
{
|
||||||
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||||
|
|
||||||
|
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
||||||
|
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
s.Email = input.Email ?? "";
|
s.Email = input.Email ?? "";
|
||||||
s.Homepage = input.Homepage ?? "";
|
s.Homepage = input.Homepage ?? "";
|
||||||
s.City = input.City ?? "";
|
s.City = input.City ?? "";
|
||||||
|
s.NameSuffix = input.NameSuffix ?? "";
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
});
|
});
|
||||||
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||||
|
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,21 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||||
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||||
|
|
||||||
|
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
|
||||||
|
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
|
||||||
|
|
||||||
|
int docxLitCount = docxLitters.Count;
|
||||||
|
int docxAnimCount = docxAnimals.Count;
|
||||||
|
int pdfLitCount = pdfLitters.Count;
|
||||||
|
int pdfAnimCount = pdfAnimals.Count;
|
||||||
|
|
||||||
|
docxLitters.AddRange(pdfLitters);
|
||||||
|
docxAnimals.AddRange(pdfAnimals);
|
||||||
|
|
||||||
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||||
{
|
{
|
||||||
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
|
||||||
"extract_docx.py zuerst ausführen.");
|
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
|
||||||
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +81,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.Select(l => new { l.Id, l.Date })
|
.Select(l => new { l.Id, l.Date })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var littersByDayNumber = littersInDb
|
var littersByDayNumber = littersInDb
|
||||||
.GroupBy(l => l.Date.DayNumber)
|
.Where(l => l.Date.HasValue)
|
||||||
|
.GroupBy(l => l.Date!.Value.DayNumber)
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||||
@@ -274,7 +286,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
DateOfDeath = deathDate,
|
DateOfDeath = deathDate,
|
||||||
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||||
ColorVarietyId = cvId == default ? null : cvId,
|
ColorVarietyId = cvId == default ? null : cvId,
|
||||||
OriginBreeder = "Zucht der Kleinen Chaoten",
|
OriginBreeder = "Zucht der kleinen Chaoten",
|
||||||
IsResident = false,
|
IsResident = false,
|
||||||
ImportSource = "docx",
|
ImportSource = "docx",
|
||||||
ExternalRef = externalRef,
|
ExternalRef = externalRef,
|
||||||
@@ -313,7 +325,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
|
||||||
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||||
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||||
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var existingLitterData = await _db.Litters
|
var existingLitterData = await _db.Litters
|
||||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||||
var existingLitterKeySet = existingLitterData
|
var existingLitterKeySet = existingLitterData
|
||||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
|
||||||
var existingLitterExtRefSet = existingLitterData
|
var existingLitterExtRefSet = existingLitterData
|
||||||
.Where(x => x.ExternalRef != null)
|
.Where(x => x.ExternalRef != null)
|
||||||
.Select(x => x.ExternalRef!).ToHashSet();
|
.Select(x => x.ExternalRef!).ToHashSet();
|
||||||
@@ -307,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||||
foreach (var l in existingLitterRows)
|
foreach (var l in existingLitterRows)
|
||||||
{
|
{
|
||||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
|
||||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var sl in synthLitters.Values.ToList())
|
foreach (var sl in synthLitters.Values.ToList())
|
||||||
{
|
{
|
||||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
|
||||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||||
{
|
{
|
||||||
// remap offspring to the existing litter; don't create a duplicate.
|
// remap offspring to the existing litter; don't create a duplicate.
|
||||||
@@ -329,8 +329,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = sl.Id,
|
Id = sl.Id,
|
||||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
|
||||||
Date = sl.Date ?? default,
|
Date = sl.Date,
|
||||||
FatherId = null, // deferred — applied after gerbils SaveChanges
|
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
MotherId = null, // deferred — applied after gerbils SaveChanges
|
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||||
@@ -571,7 +571,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
|
||||||
int herkunftBackfilled = 0;
|
int herkunftBackfilled = 0;
|
||||||
{
|
{
|
||||||
var needsHerkunft = await _db.Gerbils
|
var needsHerkunft = await _db.Gerbils
|
||||||
@@ -615,9 +615,9 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||||
if (parentFksBackfilled > 0)
|
if (parentFksBackfilled > 0)
|
||||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
if (herkunftBackfilled > 0)
|
if (herkunftBackfilled > 0)
|
||||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||||
if (farbschlagWouldRebackfill > 0)
|
if (farbschlagWouldRebackfill > 0)
|
||||||
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using GerbilManagerWebAPI.Services;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Import
|
namespace GerbilManagerWebAPI.Import
|
||||||
{
|
{
|
||||||
@@ -8,13 +10,18 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
{
|
{
|
||||||
private readonly ApplicationContext _db;
|
private readonly ApplicationContext _db;
|
||||||
private readonly string _resolvedJsonPath;
|
private readonly string _resolvedJsonPath;
|
||||||
|
private readonly string _sourceDir;
|
||||||
|
private readonly string _photoRoot;
|
||||||
|
|
||||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
var sourceDir = config["Import:SourcePath"]
|
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output"));
|
_sourceDir = config["Import:SourcePath"]
|
||||||
_resolvedJsonPath = Path.Combine(sourceDir, "resolved_import.json");
|
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||||
|
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||||
|
_photoRoot = config["Photos:RootPath"]
|
||||||
|
?? Path.Combine(contentRoot, "photo-storage");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> RunAsync()
|
public async Task<string> RunAsync()
|
||||||
@@ -55,8 +62,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||||
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||||
|
_db.SaleContracts.RemoveRange(_db.SaleContracts);
|
||||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||||
_db.Litters.RemoveRange(_db.Litters);
|
_db.Litters.RemoveRange(_db.Litters);
|
||||||
|
_db.Contacts.RemoveRange(_db.Contacts);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -72,17 +81,31 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||||
await _db.WeightRecords.ExecuteDeleteAsync();
|
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||||
|
await _db.SaleContracts.ExecuteDeleteAsync();
|
||||||
await _db.Gerbils.ExecuteDeleteAsync();
|
await _db.Gerbils.ExecuteDeleteAsync();
|
||||||
await _db.Litters.ExecuteDeleteAsync();
|
await _db.Litters.ExecuteDeleteAsync();
|
||||||
|
await _db.Contacts.ExecuteDeleteAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Import Contacts (Only add new ones to preserve manually entered contacts)
|
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
|
||||||
var existingContactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||||
var addedContactIds = new HashSet<Guid>();
|
var addedContactIds = new HashSet<Guid>();
|
||||||
int contactsAdded = 0;
|
int contactsAdded = 0;
|
||||||
|
int contactsUpdated = 0;
|
||||||
foreach (var c in data.Contacts)
|
foreach (var c in data.Contacts)
|
||||||
{
|
{
|
||||||
if (!existingContactIds.Contains(c.Id) && addedContactIds.Add(c.Id))
|
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
||||||
|
{
|
||||||
|
existingContact.Name = c.Name;
|
||||||
|
existingContact.Email = c.Email;
|
||||||
|
existingContact.Phone = c.Phone;
|
||||||
|
existingContact.Address = c.Address;
|
||||||
|
existingContact.Notes = c.Notes;
|
||||||
|
existingContact.IsBreeder = c.IsBreeder;
|
||||||
|
existingContact.IsReceiver = c.IsReceiver;
|
||||||
|
contactsUpdated++;
|
||||||
|
}
|
||||||
|
else if (addedContactIds.Add(c.Id))
|
||||||
{
|
{
|
||||||
_db.Contacts.Add(c);
|
_db.Contacts.Add(c);
|
||||||
contactsAdded++;
|
contactsAdded++;
|
||||||
@@ -117,7 +140,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var gerbilsToInsert = new List<Gerbil>();
|
var gerbilsToInsert = new List<Gerbil>();
|
||||||
foreach (var g in data.Gerbils)
|
foreach (var g in data.Gerbils)
|
||||||
{
|
{
|
||||||
gerbilsToInsert.Add(new Gerbil
|
var importedGerbil = new Gerbil
|
||||||
{
|
{
|
||||||
Id = g.Id,
|
Id = g.Id,
|
||||||
Name = g.Name,
|
Name = g.Name,
|
||||||
@@ -143,7 +166,9 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
CharacterNote = g.CharacterNote,
|
CharacterNote = g.CharacterNote,
|
||||||
IsDeaf = g.IsDeaf,
|
IsDeaf = g.IsDeaf,
|
||||||
IsResident = g.IsResident
|
IsResident = g.IsResident
|
||||||
});
|
};
|
||||||
|
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
gerbilsToInsert.Add(importedGerbil);
|
||||||
}
|
}
|
||||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
@@ -171,14 +196,45 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// 6. Insert all Photos
|
// 6. Insert all Photos & copy physical files
|
||||||
|
Directory.CreateDirectory(_photoRoot);
|
||||||
foreach (var p in data.GerbilPhotos)
|
foreach (var p in data.GerbilPhotos)
|
||||||
{
|
{
|
||||||
_db.GerbilPhotos.Add(p);
|
if (!string.IsNullOrEmpty(p.SourcePath))
|
||||||
|
{
|
||||||
|
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||||
|
var src = Path.Combine(_sourceDir, relPath);
|
||||||
|
if (File.Exists(src))
|
||||||
|
{
|
||||||
|
var dest = Path.Combine(_photoRoot, p.FileName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(src, dest, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: Source photo file not found at {src}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
GerbilId = p.GerbilId,
|
||||||
|
FileName = p.FileName,
|
||||||
|
Caption = p.Caption,
|
||||||
|
SortOrder = p.SortOrder,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return $"Ingestion successful! Imported {contactsAdded} contacts, {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +243,18 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
public List<Contact> Contacts { get; set; } = new();
|
public List<Contact> Contacts { get; set; } = new();
|
||||||
public List<Litter> Litters { get; set; } = new();
|
public List<Litter> Litters { get; set; } = new();
|
||||||
public List<Gerbil> Gerbils { get; set; } = new();
|
public List<Gerbil> Gerbils { get; set; } = new();
|
||||||
public List<GerbilPhoto> GerbilPhotos { get; set; } = new();
|
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResolvedPhoto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid GerbilId { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("_source_path")]
|
||||||
|
public string? SourcePath { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSpottingType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SpottingType",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SpottingType",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddContactRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBreeder",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsReceiver",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBreeder",
|
||||||
|
table: "Contacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsReceiver",
|
||||||
|
table: "Contacts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsCastrated : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsCastrated",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsCastrated",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBreedingNameSuffix : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "BreederSettings",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "BreederSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
||||||
|
column: "NameSuffix",
|
||||||
|
value: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "Contacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NameSuffix",
|
||||||
|
table: "BreederSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEnclosurePhotos : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EnclosurePhotos",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Caption = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
|
||||||
|
column: x => x.EnclosureId,
|
||||||
|
principalTable: "Enclosures",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EnclosurePhotos_EnclosureId",
|
||||||
|
table: "EnclosurePhotos",
|
||||||
|
column: "EnclosureId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EnclosurePhotos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeLitterDateNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<DateOnly>(
|
||||||
|
name: "Date",
|
||||||
|
table: "Litters",
|
||||||
|
type: "date",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateOnly),
|
||||||
|
oldType: "date");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<DateOnly>(
|
||||||
|
name: "Date",
|
||||||
|
table: "Litters",
|
||||||
|
type: "date",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateOnly(1, 1, 1),
|
||||||
|
oldClrType: typeof(DateOnly),
|
||||||
|
oldType: "date",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1494
GerbilManagerWebAPI/Migrations/20260613120908_AddContractAnimalPhoto.Designer.cs
generated
Normal file
1494
GerbilManagerWebAPI/Migrations/20260613120908_AddContractAnimalPhoto.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddContractAnimalPhoto : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "PhotoId",
|
||||||
|
table: "SaleContractAnimal",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PhotoId",
|
||||||
|
table: "SaleContractAnimal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("NameSuffix")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Email = "",
|
Email = "",
|
||||||
Homepage = "",
|
Homepage = "",
|
||||||
Name = "",
|
Name = "",
|
||||||
|
NameSuffix = "",
|
||||||
Phone = "",
|
Phone = "",
|
||||||
ZuchtName = ""
|
ZuchtName = ""
|
||||||
});
|
});
|
||||||
@@ -716,11 +721,20 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBreeder")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsReceiver")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.UseCollation("de-x-icu");
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
|
b.Property<string>("NameSuffix")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -750,6 +764,35 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("Enclosures");
|
b.ToTable("Enclosures");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Caption")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("EnclosureId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EnclosureId");
|
||||||
|
|
||||||
|
b.ToTable("EnclosurePhotos");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -794,6 +837,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("ImportSource")
|
b.Property<string>("ImportSource")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCastrated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeaf")
|
b.Property<bool?>("IsDeaf")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -830,6 +876,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<Guid?>("ReceiverContactId")
|
b.Property<Guid?>("ReceiverContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("SpottingType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -921,7 +970,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateOnly>("Date")
|
b.Property<DateOnly?>("Date")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<int?>("DeathsWithin8Weeks")
|
b.Property<int?>("DeathsWithin8Weeks")
|
||||||
@@ -1222,6 +1271,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<Guid>("GerbilId")
|
b.Property<Guid>("GerbilId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PhotoId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.HasKey("SaleContractId", "GerbilId");
|
b.HasKey("SaleContractId", "GerbilId");
|
||||||
|
|
||||||
b.HasIndex("GerbilId");
|
b.HasIndex("GerbilId");
|
||||||
@@ -1290,6 +1342,15 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EnclosureId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
|
|
||||||
public string ZuchtName { get; set; } = "";
|
public string ZuchtName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Namens-Anhängsel der eigenen Zucht (wie ein „Nachname"), z. B.
|
||||||
|
/// „von den kleinen Chaoten“. Wird an Namen eigener (selbst gezüchteter)
|
||||||
|
/// Tiere angehängt: „Danako von den kleinen Chaoten“.
|
||||||
|
/// </summary>
|
||||||
|
public string NameSuffix { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,14 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public bool IsBreeder { get; set; }
|
||||||
|
public bool IsReceiver { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Namens-Anhängsel dieser Zucht (wie ein „Nachname"), z. B.
|
||||||
|
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
||||||
|
/// </summary>
|
||||||
|
public string? NameSuffix { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>A photo of an enclosure (Gehege). Mirrors <see cref="GerbilPhoto"/>:
|
||||||
|
/// the file lives under the shared photo-storage root; only the file name is in the DB.</summary>
|
||||||
|
public class EnclosurePhoto
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid EnclosureId { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
|
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
|
||||||
public string? Genotype { get; set; }
|
public string? Genotype { get; set; }
|
||||||
|
|
||||||
|
public string? SpottingType { get; set; }
|
||||||
|
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
// Provenance (for the FEAT-8 spreadsheet import).
|
// Provenance (for the FEAT-8 spreadsheet import).
|
||||||
@@ -80,6 +82,7 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
||||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||||
public bool IsResident { get; set; } = true;
|
public bool IsResident { get; set; } = true;
|
||||||
|
public bool IsCastrated { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
[Key]
|
[Key]
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly? Date { get; set; }
|
||||||
|
|
||||||
/// <summary>Total born count (was "Strength").</summary>
|
/// <summary>Total born count (was "Strength").</summary>
|
||||||
public int? TotalBorn { get; set; }
|
public int? TotalBorn { get; set; }
|
||||||
|
|||||||
@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public Guid SaleContractId { get; set; }
|
public Guid SaleContractId { get; set; }
|
||||||
public Guid GerbilId { get; set; }
|
public Guid GerbilId { get; set; }
|
||||||
public Gerbil? Gerbil { get; set; }
|
public Gerbil? Gerbil { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional gewähltes Tierfoto (<see cref="GerbilPhoto"/>) fürs Vertragsbild.
|
||||||
|
/// Nur die Id wird gespeichert; beim PDF-Rendern wird die Datei nachgeladen.</summary>
|
||||||
|
public Guid? PhotoId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ namespace GerbilManagerWebAPI.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class GerbilStatusService
|
public static class GerbilStatusService
|
||||||
{
|
{
|
||||||
public const int MaxAgeYears = 7;
|
public const int MaxAgeYears = 6;
|
||||||
|
|
||||||
/// <summary>Derives and sets g.Status using the gerbil's current field values.
|
/// <summary>Derives and sets g.Status using the gerbil's current field values.
|
||||||
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
||||||
|
|||||||
106
README.md
106
README.md
@@ -1,31 +1,95 @@
|
|||||||
# GerbilManager
|
# GerbilManager
|
||||||
|
|
||||||
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
|
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
|
||||||
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
|
|
||||||
|
|
||||||
# Requirements
|
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
|
||||||
Install ef:
|
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
|
||||||
|
- `tools/import/` — Python import pipeline scripts and database schemas
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
To manage database migrations, install the EF Core CLI tool globally:
|
||||||
|
```bash
|
||||||
dotnet tool install --global dotnet-ef
|
dotnet tool install --global dotnet-ef
|
||||||
|
```
|
||||||
|
|
||||||
# Used cmd´s
|
## Running the Application
|
||||||
## Start containers
|
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
|
||||||
docker compose up
|
```bash
|
||||||
## Create the initial database
|
dotnet run --project GerbilManager.AppHost
|
||||||
dotnet ef database update
|
```
|
||||||
## Create an migration script
|
|
||||||
dotnet ef migrations add <name>
|
|
||||||
|
|
||||||
## Docker
|
---
|
||||||
### force compose rebuild
|
|
||||||
docker-compose build --no-cache
|
|
||||||
|
|
||||||
## Frontend (gerbil-manager-web)
|
## 📥 Data Import & Normalization Pipeline
|
||||||
cd gerbil-manager-web
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
|
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
|
||||||
|
|
||||||
TODO:
|
### 1. Structure of the Pipeline
|
||||||
- Backup docker volume database
|
|
||||||
- run "dotnet ef database update" if no database exist.
|
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
|
||||||
|
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
|
||||||
|
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
|
||||||
|
|
||||||
|
### 2. How to Run the Import
|
||||||
|
|
||||||
|
#### Automatic Way (All-in-One)
|
||||||
|
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manual Way
|
||||||
|
1. **Recompile the import data**:
|
||||||
|
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
|
||||||
|
```bash
|
||||||
|
python tools/import/merge_and_resolve.py
|
||||||
|
```
|
||||||
|
2. **Ingest into the running database**:
|
||||||
|
Ensure the application is running, then send an HTTP `POST` request to the API:
|
||||||
|
```bash
|
||||||
|
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Contact Deduplication Workflow
|
||||||
|
|
||||||
|
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
|
||||||
|
|
||||||
|
### 1. Search for Duplicate Contacts
|
||||||
|
You can run duplicate checks using two different similarity parameter models:
|
||||||
|
* **Round 1 (Clever matching on first 4 characters)**:
|
||||||
|
```bash
|
||||||
|
python tools/import/find_duplicates_v5.py
|
||||||
|
```
|
||||||
|
Creates: `tools/import/output/potential_duplicates_decision.md`
|
||||||
|
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
|
||||||
|
```bash
|
||||||
|
python tools/import/find_duplicates_round2.py
|
||||||
|
```
|
||||||
|
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
|
||||||
|
|
||||||
|
### 2. Fill out the Checklist
|
||||||
|
Open the generated markdown checklist file and edit the checkboxes:
|
||||||
|
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
|
||||||
|
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
|
||||||
|
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
|
||||||
|
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
|
||||||
|
|
||||||
|
*Example:*
|
||||||
|
```markdown
|
||||||
|
- [x] Max Mustermann <-- canonical name
|
||||||
|
- [ ] Max M. <-- merges into Max Mustermann
|
||||||
|
- [o] Moritz Mustermann <-- remains a separate standalone contact
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apply the Decisions
|
||||||
|
Once you have saved your decisions in the checklist file, run the parser:
|
||||||
|
```bash
|
||||||
|
python tools/import/parse_decisions.py
|
||||||
|
```
|
||||||
|
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
|
||||||
|
Run the compilation pipeline again to produce the final deduplicated import JSON:
|
||||||
|
```bash
|
||||||
|
python tools/import/merge_and_resolve.py
|
||||||
|
```
|
||||||
@@ -9,13 +9,18 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
|
|||||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// DESIGN (Rennmausakte): Charakter sind anklickbare Chips (Buttons) statt Checkboxen.
|
||||||
// Eigenschaft 'neugierig' aktivieren und speichern.
|
// Eigenschaft 'neugierig' aktivieren und speichern.
|
||||||
await page.getByRole('checkbox', { name: 'neugierig' }).check()
|
await page.getByRole('button', { name: 'neugierig', exact: true }).click()
|
||||||
await page.getByRole('button', { name: de.character.save }).click()
|
await page.getByRole('button', { name: de.character.save }).click()
|
||||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
await expect(page.getByRole('button', { name: 'neugierig', exact: true })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
|
||||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||||
|
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
|
||||||
await page.goto('/abgabe')
|
await page.goto('/abgabe')
|
||||||
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||||
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
|||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/neu')
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
const farbschlagSelect = page
|
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
|
|
||||||
.locator('select')
|
|
||||||
const genotypeInput = page
|
const genotypeInput = page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
.locator('input')
|
.locator('input')
|
||||||
@@ -18,7 +15,8 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
|||||||
await expect(genotypeInput).toHaveValue('')
|
await expect(genotypeInput).toHaveValue('')
|
||||||
|
|
||||||
// 'Agouti' wählen → Gencode wird automatisch befüllt.
|
// 'Agouti' wählen → Gencode wird automatisch befüllt.
|
||||||
await farbschlagSelect.selectOption({ label: 'Agouti' })
|
await page.locator('.color-picker-trigger').click()
|
||||||
|
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
|
||||||
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
|
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -26,54 +24,47 @@ test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) =>
|
|||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/neu')
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
const farbschlagSelect = page
|
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
|
|
||||||
.locator('select')
|
|
||||||
const genotypeInput = page
|
const genotypeInput = page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
.locator('input')
|
.locator('input')
|
||||||
|
|
||||||
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
|
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
|
||||||
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
|
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
|
||||||
await expect(farbschlagSelect).toHaveValue('cv-schwarz')
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/Schwarz/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
|
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/neu')
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
const farbschlagSelect = page
|
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
|
|
||||||
.locator('select')
|
|
||||||
const genotypeInput = page
|
const genotypeInput = page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
.locator('input')
|
.locator('input')
|
||||||
|
|
||||||
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
|
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
|
||||||
await farbschlagSelect.selectOption({ label: 'Agouti' })
|
await page.locator('.color-picker-trigger').click()
|
||||||
await expect(farbschlagSelect).not.toHaveValue('')
|
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/Agouti/)
|
||||||
|
|
||||||
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
|
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
|
||||||
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
|
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
|
||||||
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
|
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
|
||||||
await expect(farbschlagSelect).toHaveValue('')
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
|
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/neu')
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
|
||||||
const farbschlagSelect = page
|
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
|
|
||||||
.locator('select')
|
|
||||||
const genotypeInput = page
|
const genotypeInput = page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||||
.locator('input')
|
.locator('input')
|
||||||
|
|
||||||
await farbschlagSelect.selectOption({ label: 'Blau' })
|
await page.locator('.color-picker-trigger').click()
|
||||||
await expect(farbschlagSelect).not.toHaveValue('')
|
await page.locator('.color-picker-option', { hasText: 'Blau' }).click()
|
||||||
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/Blau/)
|
||||||
|
|
||||||
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
|
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
|
||||||
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
|
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
|
||||||
await expect(farbschlagSelect).toHaveValue('')
|
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
|
|||||||
|
|
||||||
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
||||||
if (!orderBy) return rows
|
if (!orderBy) return rows
|
||||||
const [field, dir] = orderBy.split(/\s+/)
|
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
|
||||||
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
|
// is "<field> [desc]"; later keys act as tiebreakers.
|
||||||
|
const keys = orderBy.split(',').map((part) => {
|
||||||
|
const [field, dir] = part.trim().split(/\s+/)
|
||||||
|
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
|
||||||
|
})
|
||||||
return [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
|
for (const { field, sign } of keys) {
|
||||||
const av = String(a[field] ?? '')
|
const av = String(a[field] ?? '')
|
||||||
const bv = String(b[field] ?? '')
|
const bv = String(b[field] ?? '')
|
||||||
return av < bv ? -sign : av > bv ? sign : 0
|
if (av < bv) return -sign
|
||||||
|
if (av > bv) return sign
|
||||||
|
}
|
||||||
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +139,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
name: 'Frau Erika Muster',
|
name: 'Frau Erika Muster',
|
||||||
address: 'Musterweg 1, 12345 Musterstadt',
|
address: 'Musterweg 1, 12345 Musterstadt',
|
||||||
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
||||||
|
nameSuffix: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (method === 'PUT') return json(route, 204)
|
if (method === 'PUT') return json(route, 204)
|
||||||
@@ -245,6 +254,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||||
|
// Gehege-Bilder (analog zu Tier-Fotos).
|
||||||
|
m = path.match(/^\/enclosures\/([^/]+)\/photos$/)
|
||||||
|
if (m) {
|
||||||
|
if (method === 'GET') return json(route, 200, [])
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||||
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
||||||
if (m) {
|
if (m) {
|
||||||
const isKruemel = m[1] === 'kruemel'
|
const isKruemel = m[1] === 'kruemel'
|
||||||
|
|||||||
@@ -136,8 +136,10 @@ export function seedDb(): MockDb {
|
|||||||
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
|
||||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||||
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
|
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
|
||||||
|
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
|
||||||
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||||
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
||||||
{
|
{
|
||||||
@@ -189,9 +191,10 @@ export function seedDb(): MockDb {
|
|||||||
|
|
||||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||||
const contacts: Contact[] = [
|
const contacts: Contact[] = [
|
||||||
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null },
|
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
|
||||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
|
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
||||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
|
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
||||||
|
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const colorVarieties = [
|
const colorVarieties = [
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
|||||||
const name = uniqueName('Tier')
|
const name = uniqueName('Tier')
|
||||||
await page.goto('/rennmaeuse/neu')
|
await page.goto('/rennmaeuse/neu')
|
||||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||||
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male })
|
await page.getByRole('button', { name: t.genderLabels.male, exact: false }).click()
|
||||||
const statusField = page.locator('label.field', {
|
const statusField = page.locator('label.field', {
|
||||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||||
})
|
})
|
||||||
@@ -54,3 +54,32 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Gehege-Feld Sichtbarkeit bei verstorbenen/abgegebenen Tieren', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
|
||||||
|
// 1. Krümel ist Breeding, hat Gehege 'Großbecken'
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
|
||||||
|
await expect(page.locator('dd', { hasText: 'Großbecken' })).toBeVisible()
|
||||||
|
|
||||||
|
// 2. Verstorbene UND abgegebene Tiere sind nicht mehr im eigenen Bestand
|
||||||
|
// -> Gehege ausgeblendet (unabhängig von der Abnehmer-Rolle).
|
||||||
|
for (const slug of ['willi', 'pauli', 'pauli-breeder', 'pauli-neither']) {
|
||||||
|
await page.goto(`/rennmaeuse/${slug}`)
|
||||||
|
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Verstorbene Tiere: Abnehmer und Inserat-Button ausgeblendet', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
|
||||||
|
// Willi ist verstorben -> "Abnehmer"-Zeile ausgeblendet und "Zur Abgabe stellen"-Button ausgeblendet
|
||||||
|
await page.goto('/rennmaeuse/willi')
|
||||||
|
await expect(page.locator('dt', { hasText: t.fields.receiver })).toBeHidden()
|
||||||
|
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
|
||||||
|
|
||||||
|
// Krümel lebt -> "Zur Abgabe stellen"-Button vorhanden
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
|
|||||||
|
|
||||||
const name = uniqueName('Testmaus')
|
const name = uniqueName('Testmaus')
|
||||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||||
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.female })
|
await page.getByRole('button', { name: t.genderLabels.female, exact: false }).click()
|
||||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
|
||||||
// Erfolg: Detailseite des neuen Tieres
|
// Erfolg: Detailseite des neuen Tieres
|
||||||
@@ -174,12 +174,13 @@ test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)'
|
|||||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||||
await page.goto('/rennmaeuse/fridolin')
|
await page.goto('/rennmaeuse/fridolin')
|
||||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||||
// Wurf K als Link sichtbar
|
// Wurf K als Link sichtbar
|
||||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||||
await expect(litterLink).toBeVisible()
|
await expect(litterLink).toBeVisible()
|
||||||
// Vater-Badge sichtbar
|
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
|
||||||
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
|
||||||
|
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
|
||||||
// Navigation zum Wurf funktioniert
|
// Navigation zum Wurf funktioniert
|
||||||
await litterLink.first().click()
|
await litterLink.first().click()
|
||||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
@@ -189,6 +190,6 @@ test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTER
|
|||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||||
await page.goto('/rennmaeuse/kruemel')
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||||
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', asy
|
|||||||
|
|
||||||
const name = uniqueName('Jungtier')
|
const name = uniqueName('Jungtier')
|
||||||
await page.getByLabel(`${tg.fields.name} *`).fill(name)
|
await page.getByLabel(`${tg.fields.name} *`).fill(name)
|
||||||
await page.getByLabel(`${tg.fields.gender} *`).selectOption({ label: tg.genderLabels.male })
|
await page.getByRole('button', { name: tg.genderLabels.male, exact: false }).click()
|
||||||
await page.getByRole('button', { name: tg.form.save, exact: true }).click()
|
await page.getByRole('button', { name: tg.form.save, exact: true }).click()
|
||||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
|
|||||||
import StammbaumPage from './pages/StammbaumPage'
|
import StammbaumPage from './pages/StammbaumPage'
|
||||||
import StatistikPage from './pages/StatistikPage'
|
import StatistikPage from './pages/StatistikPage'
|
||||||
import HilfePage from './pages/HilfePage'
|
import HilfePage from './pages/HilfePage'
|
||||||
|
import FarbkatalogPage from './pages/FarbkatalogPage'
|
||||||
import VertraegeListPage from './pages/VertraegeListPage'
|
import VertraegeListPage from './pages/VertraegeListPage'
|
||||||
import VertragWizardPage from './pages/VertragWizardPage'
|
import VertragWizardPage from './pages/VertragWizardPage'
|
||||||
import EinstellungenPage from './pages/EinstellungenPage'
|
import EinstellungenPage from './pages/EinstellungenPage'
|
||||||
@@ -70,6 +71,7 @@ export default function App() {
|
|||||||
<Route path="abgabe" element={<AbgabePage />} />
|
<Route path="abgabe" element={<AbgabePage />} />
|
||||||
<Route path="statistik" element={<StatistikPage />} />
|
<Route path="statistik" element={<StatistikPage />} />
|
||||||
<Route path="hilfe" element={<HilfePage />} />
|
<Route path="hilfe" element={<HilfePage />} />
|
||||||
|
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
||||||
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
||||||
<Route path="vertraege">
|
<Route path="vertraege">
|
||||||
<Route index element={<VertraegeListPage />} />
|
<Route index element={<VertraegeListPage />} />
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface CreateContact {
|
|||||||
phone?: string | null
|
phone?: string | null
|
||||||
address?: string | null
|
address?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
|
isBreeder?: boolean
|
||||||
|
isReceiver?: boolean
|
||||||
|
nameSuffix?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /contacts/{id}. */
|
/** Payload for PUT /contacts/{id}. */
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface CreateSaleContract {
|
|||||||
price: number
|
price: number
|
||||||
handoverDate: DateOnlyString
|
handoverDate: DateOnlyString
|
||||||
contractDate?: DateOnlyString | null
|
contractDate?: DateOnlyString | null
|
||||||
|
/** Optionales Vertragsfoto je Tier: GerbilId → GerbilPhoto-Id. */
|
||||||
|
animalPhotos?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listContracts(query: GridifyQuery): Promise<Paged<SaleContract>> {
|
export function listContracts(query: GridifyQuery): Promise<Paged<SaleContract>> {
|
||||||
@@ -40,3 +42,8 @@ export function deleteContract(id: string): Promise<void> {
|
|||||||
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
|
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
|
||||||
return `${API_BASE_URL}${contract.url}`
|
return `${API_BASE_URL}${contract.url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Absolute Download-URL des druckfertigen PDF. */
|
||||||
|
export function contractPdfUrl(contract: Pick<SaleContract, 'id'>): string {
|
||||||
|
return `${API_BASE_URL}/contracts/${contract.id}/pdf`
|
||||||
|
}
|
||||||
|
|||||||
47
gerbil-manager-web/src/api/enclosurePhotos.ts
Normal file
47
gerbil-manager-web/src/api/enclosurePhotos.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/** Fotos für Gehege (Enclosure) — analog zu api/photos, gleiche Dateiablage. */
|
||||||
|
import { API_BASE_URL, ApiError, api } from './client'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { photoSrc, type GerbilPhoto } from './photos'
|
||||||
|
|
||||||
|
/** Identical shape to a gerbil photo (PhotoDto). */
|
||||||
|
export type EnclosurePhoto = GerbilPhoto
|
||||||
|
|
||||||
|
export function listEnclosurePhotos(enclosureId: string): Promise<EnclosurePhoto[]> {
|
||||||
|
return api.get<EnclosurePhoto[]>(`/enclosures/${enclosureId}/photos`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadEnclosurePhoto(
|
||||||
|
enclosureId: string,
|
||||||
|
file: File,
|
||||||
|
caption: string | null,
|
||||||
|
): Promise<EnclosurePhoto> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
if (caption) form.append('caption', caption)
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE_URL}/enclosures/${enclosureId}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(de.api.errors.network, null)
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
response.status === 404
|
||||||
|
? de.api.errors.notFound
|
||||||
|
: response.status >= 500
|
||||||
|
? de.api.errors.server
|
||||||
|
: de.api.errors.unknown
|
||||||
|
throw new ApiError(message, response.status)
|
||||||
|
}
|
||||||
|
return (await response.json()) as EnclosurePhoto
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEnclosurePhoto(photoId: string): Promise<void> {
|
||||||
|
return api.delete(`/enclosure-photos/${photoId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { photoSrc }
|
||||||
@@ -22,13 +22,15 @@ export async function listEnclosures(): Promise<Enclosure[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listContacts(): Promise<Contact[]> {
|
export async function listContacts(): Promise<Contact[]> {
|
||||||
const paged = await api.get<Paged<Contact>>(`${resources.contacts}${ALL}`)
|
const paged = await api.get<Paged<Contact>>(
|
||||||
|
`${resources.contacts}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'name' })}`
|
||||||
|
)
|
||||||
return paged.items
|
return paged.items
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLitters(): Promise<Litter[]> {
|
export async function listLitters(): Promise<Litter[]> {
|
||||||
const paged = await api.get<Paged<Litter>>(
|
const paged = await api.get<Paged<Litter>>(
|
||||||
`${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`,
|
`${resources.litters}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'date desc' })}`,
|
||||||
)
|
)
|
||||||
return paged.items
|
return paged.items
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface BreederProfile {
|
|||||||
homepage: string
|
homepage: string
|
||||||
/** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */
|
/** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */
|
||||||
city: string
|
city: string
|
||||||
|
/** Namens-Anhängsel („Nachname") der eigenen Zucht, z. B. „von den kleinen Chaoten“. */
|
||||||
|
nameSuffix: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
||||||
@@ -20,6 +22,7 @@ export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
|||||||
email: '',
|
email: '',
|
||||||
homepage: '',
|
homepage: '',
|
||||||
city: '',
|
city: '',
|
||||||
|
nameSuffix: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */
|
/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface Gerbil {
|
|||||||
isDeaf?: boolean | null
|
isDeaf?: boolean | null
|
||||||
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
|
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
|
||||||
profilePhotoUrl?: string | null
|
profilePhotoUrl?: string | null
|
||||||
|
spottingType?: string | null
|
||||||
|
isCastrated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -83,6 +85,8 @@ export interface CreateGerbil {
|
|||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
isDeaf?: boolean | null
|
isDeaf?: boolean | null
|
||||||
|
spottingType?: string | null
|
||||||
|
isCastrated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
@@ -108,6 +112,10 @@ export interface Contact {
|
|||||||
phone: string | null
|
phone: string | null
|
||||||
address: string | null
|
address: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
isBreeder: boolean
|
||||||
|
isReceiver: boolean
|
||||||
|
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
||||||
|
nameSuffix: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Litter {
|
export interface Litter {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import GerbilIcon from './GerbilIcon'
|
import GerbilIcon from './GerbilIcon'
|
||||||
|
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||||
import './appShell.css'
|
import './appShell.css'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -31,6 +32,7 @@ const SECONDARY: NavItem[] = [
|
|||||||
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
||||||
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
|
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
|
||||||
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
|
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
|
||||||
|
{ to: '/farbkatalog', label: de.nav.colorCatalog, icon: '🎨' },
|
||||||
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -82,7 +84,9 @@ export default function AppShell() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
|
<BreederSuffixProvider>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</BreederSuffixProvider>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
17
gerbil-manager-web/src/components/BreederSuffixProvider.tsx
Normal file
17
gerbil-manager-web/src/components/BreederSuffixProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* ZUCHT-SUFFIX: provides the own-Zucht name suffix (from the breeder profile) once
|
||||||
|
* to the whole app. Consumers use `useGerbilName()` from ./breederSuffix.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { getBreederProfile } from '../api/settings'
|
||||||
|
import { useApi } from '../hooks/useApi'
|
||||||
|
import { OwnSuffixContext } from './breederSuffix'
|
||||||
|
|
||||||
|
export function BreederSuffixProvider({ children }: { children: ReactNode }) {
|
||||||
|
const profile = useApi(() => getBreederProfile(), [])
|
||||||
|
return (
|
||||||
|
<OwnSuffixContext.Provider value={profile.data?.nameSuffix ?? ''}>
|
||||||
|
{children}
|
||||||
|
</OwnSuffixContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
gerbil-manager-web/src/components/ColorVarietyPicker.css
Normal file
156
gerbil-manager-web/src/components/ColorVarietyPicker.css
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
.color-picker-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-trigger {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-height: 44px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-trigger:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-trigger-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-trigger-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-caret {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-container[aria-expanded="true"] .color-picker-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(59, 48, 38, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-search-wrapper {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-options {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 40px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-option:hover {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-option--selected {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-option-image-wrapper {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-option-image-placeholder {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-no-results {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
116
gerbil-manager-web/src/components/ColorVarietyPicker.tsx
Normal file
116
gerbil-manager-web/src/components/ColorVarietyPicker.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { type ColorVariety } from '../api/types'
|
||||||
|
import FarbschlagImage from './FarbschlagImage'
|
||||||
|
import './ColorVarietyPicker.css'
|
||||||
|
|
||||||
|
interface ColorVarietyPickerProps {
|
||||||
|
value: string
|
||||||
|
onChange: (id: string) => void
|
||||||
|
colorVarieties: ColorVariety[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorVarietyPicker({ value, onChange, colorVarieties }: ColorVarietyPickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside of it
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Find currently selected variety
|
||||||
|
const selectedVariety = colorVarieties.find((cv) => cv.id === value)
|
||||||
|
|
||||||
|
// Filter varieties by search query
|
||||||
|
const filtered = colorVarieties.filter((cv) =>
|
||||||
|
cv.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
onChange(id)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="color-picker-container" ref={containerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="color-picker-trigger"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span className="color-picker-trigger-content">
|
||||||
|
{selectedVariety ? (
|
||||||
|
<>
|
||||||
|
<FarbschlagImage name={selectedVariety.name} size={24} />
|
||||||
|
<span className="color-picker-trigger-name">{selectedVariety.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="color-picker-placeholder">{de.pages.gerbils.form.none}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="color-picker-caret">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="color-picker-dropdown">
|
||||||
|
<div className="color-picker-search-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="color-picker-search"
|
||||||
|
placeholder="Farbschlag suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="color-picker-options" role="listbox">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`color-picker-option ${!value ? 'color-picker-option--selected' : ''}`}
|
||||||
|
onClick={() => handleSelect('')}
|
||||||
|
role="option"
|
||||||
|
aria-selected={!value}
|
||||||
|
>
|
||||||
|
<div className="color-picker-option-image-placeholder" />
|
||||||
|
<span className="color-picker-option-name">{de.pages.gerbils.form.none}</span>
|
||||||
|
</button>
|
||||||
|
{filtered.map((cv) => {
|
||||||
|
const isSelected = cv.id === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cv.id}
|
||||||
|
type="button"
|
||||||
|
className={`color-picker-option ${isSelected ? 'color-picker-option--selected' : ''}`}
|
||||||
|
onClick={() => handleSelect(cv.id)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
>
|
||||||
|
<div className="color-picker-option-image-wrapper">
|
||||||
|
<FarbschlagImage name={cv.name} size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="color-picker-option-name">{cv.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="color-picker-no-results">
|
||||||
|
Keine Farbschläge gefunden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
gerbil-manager-web/src/components/EnclosurePhotosSection.tsx
Normal file
116
gerbil-manager-web/src/components/EnclosurePhotosSection.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/** Bilder eines Gehges — Galerie-Raster + Upload + Löschen (wie der Tier-Fotos-Tab). */
|
||||||
|
import { useRef, useState, type FormEvent } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import {
|
||||||
|
deleteEnclosurePhoto,
|
||||||
|
listEnclosurePhotos,
|
||||||
|
photoSrc,
|
||||||
|
uploadEnclosurePhoto,
|
||||||
|
} from '../api/enclosurePhotos'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
|
export default function EnclosurePhotosSection({ enclosureId }: { enclosureId: string }) {
|
||||||
|
const tp = de.pages.tierTabs.photos
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const photos = useApi(() => listEnclosurePhotos(enclosureId), [enclosureId])
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
const [caption, setCaption] = useState('')
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const upload = useMutation((file: File) =>
|
||||||
|
uploadEnclosurePhoto(enclosureId, file, caption.trim() === '' ? null : caption.trim()),
|
||||||
|
)
|
||||||
|
const removal = useMutation((photoId: string) => deleteEnclosurePhoto(photoId))
|
||||||
|
|
||||||
|
async function onUpload(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = fileInput.current?.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
setFileError(tp.validation.fileRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFileError(null)
|
||||||
|
const r = await upload.run(file)
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
setCaption('')
|
||||||
|
if (fileInput.current) fileInput.current.value = ''
|
||||||
|
photos.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(r.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(photoId: string) {
|
||||||
|
if (!window.confirm(tp.deleteConfirm)) return
|
||||||
|
const r = await removal.run(photoId)
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(de.common.deleted)
|
||||||
|
photos.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(r.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = photos.data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form className="form" onSubmit={onUpload} noValidate>
|
||||||
|
<label className="field">
|
||||||
|
<span>{tp.chooseFile}</span>
|
||||||
|
<input ref={fileInput} type="file" accept="image/*" className="input" />
|
||||||
|
{fileError && <small className="error-text">{fileError}</small>}
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{tp.captionLabel}</span>
|
||||||
|
<input className="input" value={caption} onChange={(e) => setCaption(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{upload.error && <div className="alert alert--error">{upload.error}</div>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn--primary" disabled={upload.pending}>
|
||||||
|
{upload.pending ? tp.uploading : tp.uploadButton}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{photos.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{photos.error && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{photos.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={photos.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{removal.error && <div className="alert alert--error">{removal.error}</div>}
|
||||||
|
|
||||||
|
{!photos.loading && !photos.error && items.length === 0 && <p className="muted">{tp.empty}</p>}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<ul className="photo-grid">
|
||||||
|
{[...items]
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((p) => (
|
||||||
|
<li key={p.id} className="photo-card">
|
||||||
|
<img src={photoSrc(p)} alt={p.caption ?? p.fileName} loading="lazy" />
|
||||||
|
<div className="photo-card__bar">
|
||||||
|
<span className="photo-card__caption">{p.caption ?? ''}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
onClick={() => onDelete(p.id)}
|
||||||
|
disabled={removal.pending}
|
||||||
|
>
|
||||||
|
{de.common.delete}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* FEAT-6: Profilfoto im Detail-Kopf — erstes Foto nach sortOrder.
|
* FEAT-6: Profilfoto — erstes Foto nach sortOrder. Selbstständig ladend.
|
||||||
* Selbstständig ladend; rendert nichts, solange es keine Fotos gibt
|
* Ohne Foto rendert es `fallback` (oder nichts).
|
||||||
* (oder die Foto-Endpunkte noch nicht live sind).
|
|
||||||
*/
|
*/
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
|
|
||||||
export default function GerbilProfilePhoto({ gerbilId }: { gerbilId: string }) {
|
interface GerbilProfilePhotoProps {
|
||||||
|
gerbilId: string
|
||||||
|
/** CSS class for the <img> (default: the small detail-header avatar). */
|
||||||
|
className?: string
|
||||||
|
/** Rendered when the gerbil has no photo yet (e.g. a hero placeholder). */
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GerbilProfilePhoto({
|
||||||
|
gerbilId,
|
||||||
|
className = 'profile-photo',
|
||||||
|
fallback = null,
|
||||||
|
}: GerbilProfilePhotoProps) {
|
||||||
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
|
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
|
||||||
const profile = profilePhoto(photos.data ?? [])
|
const profile = profilePhoto(photos.data ?? [])
|
||||||
if (!profile) return null
|
if (!profile) return <>{fallback}</>
|
||||||
return (
|
return <img className={className} src={photoSrc(profile)} alt={profile.caption ?? profile.fileName} />
|
||||||
<img
|
|
||||||
className="profile-photo"
|
|
||||||
src={photoSrc(profile)}
|
|
||||||
alt={profile.caption ?? profile.fileName}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatDate, genderLabel } from '../format/labels'
|
|||||||
import { traitLabels } from '../format/traits'
|
import { traitLabels } from '../format/traits'
|
||||||
import FarbschlagImage from './FarbschlagImage'
|
import FarbschlagImage from './FarbschlagImage'
|
||||||
import Charakterbogen from './Charakterbogen'
|
import Charakterbogen from './Charakterbogen'
|
||||||
|
import { useGerbilName } from './breederSuffix'
|
||||||
|
|
||||||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function groupGender(animals: Gerbil[]): string {
|
|||||||
|
|
||||||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||||||
const t = de.pages.abgabe
|
const t = de.pages.abgabe
|
||||||
|
const gerbilName = useGerbilName()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [status, setStatus] = useState<SaleStatus>('free')
|
const [status, setStatus] = useState<SaleStatus>('free')
|
||||||
const [reservedName, setReservedName] = useState('')
|
const [reservedName, setReservedName] = useState('')
|
||||||
@@ -203,7 +205,7 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
|||||||
<div key={a.id} className="group-composer__animal">
|
<div key={a.id} className="group-composer__animal">
|
||||||
<div className="group-composer__animal-head">
|
<div className="group-composer__animal-head">
|
||||||
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
||||||
<strong>{a.name}</strong>
|
<strong>{gerbilName(a) || a.name}</strong>
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
||||||
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
||||||
|
|||||||
43
gerbil-manager-web/src/components/ToastProvider.tsx
Normal file
43
gerbil-manager-web/src/components/ToastProvider.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** Toast notifications: provider + auto-dismissing toast stack. */
|
||||||
|
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { ToastContext, type ToastApi, type ToastVariant } from './toast'
|
||||||
|
import './toast.css'
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
variant: ToastVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
|
const idRef = useRef(0)
|
||||||
|
const remove = useCallback((id: number) => setToasts((ts) => ts.filter((t) => t.id !== id)), [])
|
||||||
|
|
||||||
|
const api = useMemo<ToastApi>(() => {
|
||||||
|
const show = (message: string, variant: ToastVariant = 'info') => {
|
||||||
|
const id = (idRef.current += 1)
|
||||||
|
setToasts((ts) => [...ts, { id, message, variant }])
|
||||||
|
window.setTimeout(() => remove(id), 3500)
|
||||||
|
}
|
||||||
|
return { show, success: (m) => show(m, 'success'), error: (m) => show(m, 'error') }
|
||||||
|
}, [remove])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" role="status" aria-live="polite">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className={`toast toast--${t.variant}`}
|
||||||
|
onClick={() => remove(t.id)}
|
||||||
|
>
|
||||||
|
{t.message}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
gerbil-manager-web/src/components/breederSuffix.ts
Normal file
17
gerbil-manager-web/src/components/breederSuffix.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* ZUCHT-SUFFIX: the own-Zucht name-suffix context + the `useGerbilName()` helper.
|
||||||
|
* Kept separate from the provider component so each module has a single concern
|
||||||
|
* (and satisfies react-refresh's "components-only" rule for .tsx files).
|
||||||
|
*/
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import { gerbilDisplayName } from '../format/gerbilName'
|
||||||
|
import type { Gerbil } from '../api/types'
|
||||||
|
|
||||||
|
export const OwnSuffixContext = createContext<string>('')
|
||||||
|
|
||||||
|
/** Returns a function that renders a gerbil's display name incl. the Zucht suffix. */
|
||||||
|
export function useGerbilName() {
|
||||||
|
const ownSuffix = useContext(OwnSuffixContext)
|
||||||
|
return (g: Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>): string =>
|
||||||
|
gerbilDisplayName(g, ownSuffix)
|
||||||
|
}
|
||||||
@@ -65,3 +65,54 @@
|
|||||||
color: #d97706;
|
color: #d97706;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible details styling for the Charakter & Eigenschaften section */
|
||||||
|
.charakter-details {
|
||||||
|
border: 1px solid var(--color-border, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--color-accent-soft, #f9f9f9);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-summary:hover {
|
||||||
|
background: var(--color-hover, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-summary h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-chevron {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-details[open] > .charakter-summary .charakter-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.charakter-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-border, #ddd);
|
||||||
|
}
|
||||||
|
|||||||
55
gerbil-manager-web/src/components/toast.css
Normal file
55
gerbil-manager-web/src/components/toast.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* Toast stack: centered above the mobile action bar (normal bottom inset on desktop). */
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
box-shadow: 0 6px 24px rgba(43, 33, 25, 0.28);
|
||||||
|
text-align: center;
|
||||||
|
animation: toastIn 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toast--success {
|
||||||
|
background: #5e8c5a;
|
||||||
|
}
|
||||||
|
.toast--error {
|
||||||
|
background: #a6433a;
|
||||||
|
}
|
||||||
|
.toast--info {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.toast-stack {
|
||||||
|
bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
gerbil-manager-web/src/components/toast.ts
Normal file
19
gerbil-manager-web/src/components/toast.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** Toast notifications: the context + `useToast()` hook (kept separate from the
|
||||||
|
* provider component so each module has a single concern / fast-refresh stays happy). */
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export type ToastVariant = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
export interface ToastApi {
|
||||||
|
show: (message: string, variant?: ToastVariant) => void
|
||||||
|
success: (message: string) => void
|
||||||
|
error: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP: ToastApi = { show: () => {}, success: () => {}, error: () => {} }
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastApi>(NOOP)
|
||||||
|
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
return useContext(ToastContext)
|
||||||
|
}
|
||||||
24
gerbil-manager-web/src/format/gerbilName.ts
Normal file
24
gerbil-manager-web/src/format/gerbilName.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* ZUCHT-SUFFIX: a gerbil's display name with the own-Zucht "suffix" (like a last
|
||||||
|
* name) appended — "Danako" + "von den kleinen Chaoten" → "Danako von den kleinen
|
||||||
|
* Chaoten". Per the chosen rule, only OWN-bred animals get the suffix; animals from
|
||||||
|
* other breeders keep their plain name.
|
||||||
|
*/
|
||||||
|
import type { Gerbil } from '../api/types'
|
||||||
|
|
||||||
|
type NameParts = Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>
|
||||||
|
|
||||||
|
/** Own-bred = no external origin recorded (no origin contact, no freetext breeder). */
|
||||||
|
export function isOwnBred(g: NameParts): boolean {
|
||||||
|
return !g.originContactId && !(g.originBreeder && g.originBreeder.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name + own-Zucht suffix for own-bred animals; plain name otherwise.
|
||||||
|
* Returns '' for a nameless animal (callers fall back to the "(ohne Namen)" label).
|
||||||
|
*/
|
||||||
|
export function gerbilDisplayName(g: NameParts, ownSuffix: string): string {
|
||||||
|
if (!g.name) return ''
|
||||||
|
const suffix = ownSuffix.trim()
|
||||||
|
return suffix && isOwnBred(g) ? `${g.name} ${suffix}` : g.name
|
||||||
|
}
|
||||||
@@ -73,10 +73,15 @@ export function useInfiniteList<T>(
|
|||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Tracks an in-flight request synchronously so loadMore() can't advance (and
|
||||||
|
// re-fetch/append) the same page twice if the scroll sentinel fires repeatedly.
|
||||||
|
const inFlightRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const page = req.page
|
const page = req.page
|
||||||
const id = `${req.nonce}:${req.key}#${page}`
|
const id = `${req.nonce}:${req.key}#${page}`
|
||||||
|
inFlightRef.current = true
|
||||||
loaderRef
|
loaderRef
|
||||||
.current(page)
|
.current(page)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -92,6 +97,9 @@ export function useInfiniteList<T>(
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
|
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) inFlightRef.current = false
|
||||||
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
@@ -109,8 +117,35 @@ export function useInfiniteList<T>(
|
|||||||
const error = inFlight ? null : sameQuery ? resolved.error : null
|
const error = inFlight ? null : sameQuery ? resolved.error : null
|
||||||
const hasMore = sameQuery && !inFlight && items.length < total
|
const hasMore = sameQuery && !inFlight && items.length < total
|
||||||
|
|
||||||
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), [])
|
const loadMore = useCallback(() => {
|
||||||
|
if (inFlightRef.current) return
|
||||||
|
setReq((r) => ({ ...r, page: r.page + 1 }))
|
||||||
|
}, [])
|
||||||
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
|
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
|
||||||
|
|
||||||
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
|
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires an IntersectionObserver to a sentinel element that triggers loadMore() as
|
||||||
|
* it scrolls into view. Render the returned ref on a small element shown only while
|
||||||
|
* `list.hasMore`, e.g. `{list.hasMore && <div ref={sentinelRef} />}`. Re-subscribes
|
||||||
|
* when the load state changes so it keeps paging while the sentinel stays in view.
|
||||||
|
*/
|
||||||
|
export function useInfiniteSentinel<T>(list: InfiniteListState<T>) {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
|
const { hasMore, loading, loadingMore, loadMore } = list
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) loadMore()
|
||||||
|
},
|
||||||
|
{ rootMargin: '300px 0px' },
|
||||||
|
)
|
||||||
|
io.observe(el)
|
||||||
|
return () => io.disconnect()
|
||||||
|
}, [hasMore, loading, loadingMore, loadMore])
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|||||||
@@ -348,13 +348,65 @@ textarea {
|
|||||||
border: 1px dashed #bbb;
|
border: 1px dashed #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
|
/* Checkbox-style filter (label text + toggle on one row), vs. the stacked .field. */
|
||||||
.field--check {
|
.field--check {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toggle-switch styling for boolean checkboxes + the multi-select pickers
|
||||||
|
(mobile-friendlier than a checkbox). The element stays a real <input
|
||||||
|
type="checkbox"> — role, keyboard and form semantics are unchanged. */
|
||||||
|
.field--check input[type='checkbox'],
|
||||||
|
.wizard-pick input[type='checkbox'],
|
||||||
|
.namegen-usages-grid input[type='checkbox'] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: relative;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
.field--check input[type='checkbox']::after,
|
||||||
|
.wizard-pick input[type='checkbox']::after,
|
||||||
|
.namegen-usages-grid input[type='checkbox']::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: calc(1.45rem - 4px);
|
||||||
|
height: calc(1.45rem - 4px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(61, 46, 35, 0.35);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.field--check input[type='checkbox']:checked,
|
||||||
|
.wizard-pick input[type='checkbox']:checked,
|
||||||
|
.namegen-usages-grid input[type='checkbox']:checked {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
.field--check input[type='checkbox']:checked::after,
|
||||||
|
.wizard-pick input[type='checkbox']:checked::after,
|
||||||
|
.namegen-usages-grid input[type='checkbox']:checked::after {
|
||||||
|
transform: translateX(calc(2.5rem - 1.45rem));
|
||||||
|
}
|
||||||
|
.field--check input[type='checkbox']:focus-visible,
|
||||||
|
.wizard-pick input[type='checkbox']:focus-visible,
|
||||||
|
.namegen-usages-grid input[type='checkbox']:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@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;
|
||||||
@@ -796,10 +848,149 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Profilfoto im Detail-Kopf */
|
/* Profilfoto im Detail-Kopf */
|
||||||
.profile-photo {
|
.profile-header-summary {
|
||||||
width: 56px;
|
display: flex;
|
||||||
height: 56px;
|
align-items: center;
|
||||||
border-radius: 50%;
|
gap: 1.5rem;
|
||||||
object-fit: cover;
|
}
|
||||||
border: 2px solid var(--color-border);
|
|
||||||
|
.profile-header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-photo {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 4px solid var(--color-border);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-photo:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-2col {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-row-2col > :first-child {
|
||||||
|
flex: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.form-row-2col > :last-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-box {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: inset 0 1px 3px rgba(59, 48, 38, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.5rem 0 1.25rem 0;
|
||||||
|
max-width: 32rem; /* match form max-width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-box__title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
border-bottom: 1px dashed var(--color-border);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gender Segmented Control / Radio Buttons */
|
||||||
|
.gender-selector-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn__icon {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active styles */
|
||||||
|
.gender-btn--male.is-active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn--male.is-active .gender-btn__icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn--female.is-active {
|
||||||
|
border-color: #ec4899;
|
||||||
|
background: #fdf2f8;
|
||||||
|
color: #be185d;
|
||||||
|
box-shadow: 0 0 0 1px #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn--female.is-active .gender-btn__icon {
|
||||||
|
color: #db2777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn--unknown.is-active {
|
||||||
|
border-color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
box-shadow: 0 0 0 1px #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-btn--unknown.is-active .gender-btn__icon {
|
||||||
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
import { installUnhandledRejectionGuard } from './dev/unhandledRejectionGuard'
|
import { installUnhandledRejectionGuard } from './dev/unhandledRejectionGuard'
|
||||||
|
|
||||||
installUnhandledRejectionGuard()
|
installUnhandledRejectionGuard()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import type { Gerbil } from '../api/types'
|
|||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||||
import GroupComposer from '../components/GroupComposer'
|
import GroupComposer from '../components/GroupComposer'
|
||||||
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
import './abgabe.css'
|
import './abgabe.css'
|
||||||
|
|
||||||
const UNGROUPED = 'ungrouped'
|
const UNGROUPED = 'ungrouped'
|
||||||
|
|
||||||
export default function AbgabePage() {
|
export default function AbgabePage() {
|
||||||
const t = de.pages.abgabe
|
const t = de.pages.abgabe
|
||||||
|
const gerbilName = useGerbilName()
|
||||||
|
|
||||||
const forSale = useApi(
|
const forSale = useApi(
|
||||||
() =>
|
() =>
|
||||||
@@ -82,7 +84,7 @@ export default function AbgabePage() {
|
|||||||
<ul className="abgabe-roster__list">
|
<ul className="abgabe-roster__list">
|
||||||
{animals.map((a) => (
|
{animals.map((a) => (
|
||||||
<li key={a.id}>
|
<li key={a.id}>
|
||||||
<span>{a.name}</span>
|
<span>{gerbilName(a) || a.name}</span>
|
||||||
<select
|
<select
|
||||||
value={assignment(a)}
|
value={assignment(a)}
|
||||||
onChange={(e) => moveTo(a.id, e.target.value)}
|
onChange={(e) => moveTo(a.id, e.target.value)}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
syncRequests,
|
syncRequests,
|
||||||
type RequestStatus,
|
type RequestStatus,
|
||||||
} from '../api/requests'
|
} from '../api/requests'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useMutation } from '../hooks/useApi'
|
||||||
|
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||||
import { formatDateTime } from '../format/labels'
|
import { formatDateTime } from '../format/labels'
|
||||||
import { FilterPanel } from '../components/FilterPanel'
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './anfragen.css'
|
import './anfragen.css'
|
||||||
@@ -31,19 +32,20 @@ export function StatusBadge({ status }: { status: RequestStatus }) {
|
|||||||
export default function AnfragenPage() {
|
export default function AnfragenPage() {
|
||||||
const t = de.pages.anfragen
|
const t = de.pages.anfragen
|
||||||
const [status, setStatus] = useState<RequestStatus | ''>('')
|
const [status, setStatus] = useState<RequestStatus | ''>('')
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [notice, setNotice] = useState<string | null>(null)
|
const [notice, setNotice] = useState<string | null>(null)
|
||||||
|
|
||||||
const requests = useApi(
|
const requests = useInfiniteList(
|
||||||
() =>
|
(page) =>
|
||||||
listRequests({
|
listRequests({
|
||||||
page,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
orderBy: 'receivedAt desc',
|
orderBy: 'receivedAt desc,id',
|
||||||
filter: status === '' ? undefined : `status==${status}`,
|
filter: status === '' ? undefined : `status==${status}`,
|
||||||
}),
|
}),
|
||||||
[page, status],
|
`${status}`,
|
||||||
)
|
)
|
||||||
|
const sentinelRef = useInfiniteSentinel(requests)
|
||||||
|
const { items, total, loading } = requests
|
||||||
|
|
||||||
const sync = useMutation(() => syncRequests())
|
const sync = useMutation(() => syncRequests())
|
||||||
async function onSync() {
|
async function onSync() {
|
||||||
@@ -63,7 +65,7 @@ export default function AnfragenPage() {
|
|||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
|
{!loading && <p className="muted">{t.countText(total)}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
||||||
@@ -78,16 +80,13 @@ export default function AnfragenPage() {
|
|||||||
{/* Status-Filter */}
|
{/* Status-Filter */}
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
activeCount={status !== '' ? 1 : 0}
|
activeCount={status !== '' ? 1 : 0}
|
||||||
onReset={() => { setStatus(''); setPage(1) }}
|
onReset={() => setStatus('')}
|
||||||
>
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.detail.statusLabel}</span>
|
<span>{t.detail.statusLabel}</span>
|
||||||
<select
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => {
|
onChange={(e) => setStatus(e.target.value as RequestStatus | '')}
|
||||||
setStatus(e.target.value as RequestStatus | '')
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="">{t.filterAll}</option>
|
<option value="">{t.filterAll}</option>
|
||||||
{REQUEST_STATUSES.map((s) => (
|
{REQUEST_STATUSES.map((s) => (
|
||||||
@@ -99,7 +98,7 @@ export default function AnfragenPage() {
|
|||||||
</label>
|
</label>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
{loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{requests.error && (
|
{requests.error && (
|
||||||
<div className="alert alert--error">
|
<div className="alert alert--error">
|
||||||
<span>{requests.error}</span>
|
<span>{requests.error}</span>
|
||||||
@@ -109,13 +108,13 @@ export default function AnfragenPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requests.data && requests.data.items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requests.data && requests.data.items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{requests.data.items.map((r) => (
|
{items.map((r) => (
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
||||||
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
||||||
@@ -130,24 +129,12 @@ export default function AnfragenPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requests.data && requests.data.totalCount > PAGE_SIZE && (
|
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||||
<nav className="pager" aria-label="Seitennavigation">
|
{requests.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
{requests.loadingMore && (
|
||||||
{de.common.previous}
|
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||||
</button>
|
{de.common.loading}
|
||||||
<span>
|
</p>
|
||||||
{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>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { listGerbils } from '../api/gerbils'
|
|||||||
import { listColorVarieties } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
import { condition } from '../api/gridify'
|
import { condition } from '../api/gridify'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
|
||||||
|
|
||||||
export default function BeckenDetailPage() {
|
export default function BeckenDetailPage() {
|
||||||
const t = de.pages.becken
|
const t = de.pages.becken
|
||||||
@@ -109,6 +110,9 @@ export default function BeckenDetailPage() {
|
|||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<h3>{t.photosTitle}</h3>
|
||||||
|
<EnclosurePhotosSection enclosureId={e.id} />
|
||||||
|
|
||||||
<h3>{t.occupancy.title}</h3>
|
<h3>{t.occupancy.title}</h3>
|
||||||
{occupants.loading && <p className="muted">{de.common.loading}</p>}
|
{occupants.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{occupants.error && (
|
{occupants.error && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
|
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
@@ -18,6 +19,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
|||||||
export default function BeckenFormPage() {
|
export default function BeckenFormPage() {
|
||||||
const t = de.pages.becken
|
const t = de.pages.becken
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
@@ -45,8 +47,13 @@ export default function BeckenFormPage() {
|
|||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||||
if (result.ok) navigate(`/gehege/${result.value.id}`)
|
if (result.ok) {
|
||||||
// On failure mutation.error drives the inline alert; run() never throws.
|
toast.success(de.common.saved)
|
||||||
|
navigate(`/gehege/${result.value.id}`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
// On failure mutation.error also drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
|||||||
@@ -1,35 +1,34 @@
|
|||||||
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
|
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { listEnclosuresPaged } from '../api/enclosures'
|
import { listEnclosuresPaged } from '../api/enclosures'
|
||||||
import { condition, type GridifyQuery } from '../api/gridify'
|
import { condition } from '../api/gridify'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export default function BeckenPage() {
|
export default function BeckenPage() {
|
||||||
const t = de.pages.becken
|
const t = de.pages.becken
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
|
|
||||||
const filter = search.trim()
|
const filter = search.trim()
|
||||||
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
||||||
: undefined
|
: undefined
|
||||||
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
|
// Infinite scroll; trailing `id` keeps OFFSET pagination stable (no repeats/omits).
|
||||||
|
const list = useInfiniteList(
|
||||||
const enclosures = useApi(() => listEnclosuresPaged(query), [filter, page])
|
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||||
|
`${filter ?? ''}`,
|
||||||
const total = enclosures.data?.totalCount ?? 0
|
)
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const sentinelRef = useInfiniteSentinel(list)
|
||||||
const items = enclosures.data?.items ?? []
|
const { items, total, loading } = list
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
{enclosures.data && (
|
{!loading && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{total} {t.countLabel}
|
{total} {t.countLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -46,27 +45,22 @@ export default function BeckenPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
placeholder={t.searchPlaceholder}
|
placeholder={t.searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
setSearch(e.target.value)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
aria-label={t.fields.name}
|
aria-label={t.fields.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enclosures.loading && <p className="muted">{de.common.loading}</p>}
|
{loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{enclosures.error && (
|
{list.error && (
|
||||||
<div className="alert alert--error">
|
<div className="alert alert--error">
|
||||||
<span>{enclosures.error}</span>
|
<span>{list.error}</span>
|
||||||
<button type="button" className="btn" onClick={enclosures.reload}>
|
<button type="button" className="btn" onClick={list.reload}>
|
||||||
{de.common.retry}
|
{de.common.retry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!enclosures.loading && !enclosures.error && items.length === 0 && (
|
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
|
||||||
<p className="muted">{t.empty}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
@@ -81,28 +75,12 @@ export default function BeckenPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||||
<nav className="pager" aria-label="Seitennavigation">
|
{list.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||||
<button
|
{list.loadingMore && (
|
||||||
type="button"
|
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||||
className="btn"
|
{de.common.loading}
|
||||||
disabled={page <= 1}
|
</p>
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
{de.common.previous}
|
|
||||||
</button>
|
|
||||||
<span className="muted">
|
|
||||||
{de.common.page} {page} {de.common.of} {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
{de.common.next}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import {
|
|||||||
type BreederProfile,
|
type BreederProfile,
|
||||||
} from '../api/settings'
|
} from '../api/settings'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
// EXPORT-1 (Oscar): Datenexport-Karte
|
// EXPORT-1 (Oscar): Datenexport-Karte
|
||||||
import DatenexportCard from '../components/DatenexportCard'
|
import DatenexportCard from '../components/DatenexportCard'
|
||||||
|
|
||||||
export default function EinstellungenPage() {
|
export default function EinstellungenPage() {
|
||||||
const t = de.pages.einstellungen
|
const t = de.pages.einstellungen
|
||||||
const tz = t.zuchtprofil
|
const tz = t.zuchtprofil
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
|
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
@@ -40,7 +42,12 @@ export default function EinstellungenPage() {
|
|||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const result = await mutation.run(form)
|
const result = await mutation.run(form)
|
||||||
if (result.ok) setSaved(true)
|
if (result.ok) {
|
||||||
|
setSaved(true)
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
@@ -81,6 +88,7 @@ export default function EinstellungenPage() {
|
|||||||
|
|
||||||
<form className="form" onSubmit={onSubmit} noValidate>
|
<form className="form" onSubmit={onSubmit} noValidate>
|
||||||
{field('zuchtName', tz.fields.zuchtName)}
|
{field('zuchtName', tz.fields.zuchtName)}
|
||||||
|
{field('nameSuffix', tz.fields.nameSuffix, tz.fields.nameSuffixHint)}
|
||||||
{field('name', tz.fields.name, tz.fields.nameHint)}
|
{field('name', tz.fields.name, tz.fields.nameHint)}
|
||||||
{field('address', tz.fields.address, tz.fields.addressHint)}
|
{field('address', tz.fields.address, tz.fields.addressHint)}
|
||||||
{field('city', tz.fields.city)}
|
{field('city', tz.fields.city)}
|
||||||
|
|||||||
81
gerbil-manager-web/src/pages/FarbkatalogPage.tsx
Normal file
81
gerbil-manager-web/src/pages/FarbkatalogPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { CATALOG, farbschlagImageUrl } from '../genetics'
|
||||||
|
import './farbkatalog.css'
|
||||||
|
|
||||||
|
export default function FarbkatalogPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
// Filter color catalog by query
|
||||||
|
const filtered = CATALOG.filter((variety) => {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
variety.name.toLowerCase().includes(q) ||
|
||||||
|
(variety.english && variety.english.toLowerCase().includes(q)) ||
|
||||||
|
variety.canonicalGenotype.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page farbkatalog-page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{de.pages.farbkatalog.title}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{de.pages.farbkatalog.subtitle.replace('{count}', String(CATALOG.length))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="farbkatalog-search-bar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="input"
|
||||||
|
placeholder={de.pages.farbkatalog.searchPlaceholder}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="farbkatalog-grid">
|
||||||
|
{filtered.map((variety) => {
|
||||||
|
const imgUrl = farbschlagImageUrl(variety.image)
|
||||||
|
return (
|
||||||
|
<div key={variety.name} className="farbkatalog-card">
|
||||||
|
<div className="farbkatalog-card-media">
|
||||||
|
{imgUrl ? (
|
||||||
|
<img
|
||||||
|
src={imgUrl}
|
||||||
|
alt={variety.name}
|
||||||
|
className="farbkatalog-card-img"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="farbkatalog-card-media-placeholder">
|
||||||
|
<span>{variety.name.substring(0, 2).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="farbkatalog-card-body">
|
||||||
|
<h4 className="farbkatalog-card-title">{variety.name}</h4>
|
||||||
|
{variety.english && (
|
||||||
|
<span className="farbkatalog-card-subtitle">{variety.english}</span>
|
||||||
|
)}
|
||||||
|
<div className="farbkatalog-card-genotype">
|
||||||
|
<code>{variety.canonicalGenotype}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="farbkatalog-empty">
|
||||||
|
<span className="farbkatalog-empty-icon">🔍</span>
|
||||||
|
<p>{de.pages.farbkatalog.empty}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,17 +6,30 @@ import { listLitters as listLittersPaged } from '../api/litters'
|
|||||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
|
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||||
|
import type { Gender, GerbilStatus } from '../api/types'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
import Charakterbogen from '../components/Charakterbogen'
|
|
||||||
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
|
|
||||||
import GerbilHealthTab from '../components/GerbilHealthTab'
|
import GerbilHealthTab from '../components/GerbilHealthTab'
|
||||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||||
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
||||||
import GerbilWeightTab from '../components/GerbilWeightTab'
|
import GerbilWeightTab from '../components/GerbilWeightTab'
|
||||||
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
|
import './rennmausakte.css'
|
||||||
|
|
||||||
type DetailTab = 'photos' | 'health' | 'weight'
|
type DetailTab = 'photos' | 'health' | 'weight'
|
||||||
|
|
||||||
|
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
|
||||||
|
const STATUS_COLORS: Record<GerbilStatus, string> = {
|
||||||
|
Breeding: '#5e8c5a',
|
||||||
|
Pet: '#5a7da8',
|
||||||
|
ForSale: '#c2703d',
|
||||||
|
Deceased: '#8a847b',
|
||||||
|
GivenAway: '#b08e5c',
|
||||||
|
}
|
||||||
|
const GENDER_SYMBOL: Record<Gender, string> = { unknown: '?', male: '♂', female: '♀' }
|
||||||
|
|
||||||
/** Parse a stored genotype string and derive its normalized form + Farbschlag. */
|
/** Parse a stored genotype string and derive its normalized form + Farbschlag. */
|
||||||
function describeGenotype(genotype: string | null): { display: string; farbschlag: string } | null {
|
function describeGenotype(genotype: string | null): { display: string; farbschlag: string } | null {
|
||||||
if (!genotype || !genotype.trim()) return null
|
if (!genotype || !genotype.trim()) return null
|
||||||
@@ -24,22 +37,24 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
|
|||||||
const g = fromDisplayString(genotype)
|
const g = fromDisplayString(genotype)
|
||||||
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
|
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
|
||||||
} catch {
|
} catch {
|
||||||
// Unparseable: show the safely-rendered raw value, no derived Farbschlag.
|
|
||||||
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
|
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: ReactNode }) {
|
/** A labelled key/value row inside a card (semantic dt/dd, styled as a grid). */
|
||||||
|
function Kv({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="def-row">
|
<div className="ak-kv">
|
||||||
<dt>{label}</dt>
|
<dt className="ak-kv-label">{label}</dt>
|
||||||
<dd>{value ?? '—'}</dd>
|
<dd className="ak-kv-value">{children}</dd>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GerbilDetailPage() {
|
export default function GerbilDetailPage() {
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
const gerbilName = useGerbilName()
|
||||||
|
const toast = useToast()
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [tab, setTab] = useState<DetailTab>('photos')
|
const [tab, setTab] = useState<DetailTab>('photos')
|
||||||
|
|
||||||
@@ -49,6 +64,9 @@ export default function GerbilDetailPage() {
|
|||||||
const [charTraits, setCharTraits] = useState<string[]>([])
|
const [charTraits, setCharTraits] = useState<string[]>([])
|
||||||
const [charNote, setCharNote] = useState('')
|
const [charNote, setCharNote] = useState('')
|
||||||
const [charInit, setCharInit] = useState<string | null>(null)
|
const [charInit, setCharInit] = useState<string | null>(null)
|
||||||
|
// Charakter ist v. a. für lebende Bestandstiere relevant → bei Zucht/Liebhaber
|
||||||
|
// aufgeklappt, sonst (Abgabe/Verstorben/Abgegeben) eingeklappt (manuell öffenbar).
|
||||||
|
const [charExpanded, setCharExpanded] = useState(true)
|
||||||
const saveCharacter = useMutation(() =>
|
const saveCharacter = useMutation(() =>
|
||||||
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
|
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
|
||||||
)
|
)
|
||||||
@@ -78,6 +96,27 @@ export default function GerbilDetailPage() {
|
|||||||
[litters.data],
|
[litters.data],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Parents come from the gerbil's own litter (litterId → litter.fatherId/motherId).
|
||||||
|
// Fetch each parent gerbil for its name + link (mirrors WurfDetailPage).
|
||||||
|
const ownLitterId = gerbil.data?.litterId ?? null
|
||||||
|
const ownLitter = useMemo(
|
||||||
|
() => (ownLitterId ? ((litters.data ?? []).find((l) => l.id === ownLitterId) ?? null) : null),
|
||||||
|
[litters.data, ownLitterId],
|
||||||
|
)
|
||||||
|
const fatherId = ownLitter?.fatherId ?? null
|
||||||
|
const motherId = ownLitter?.motherId ?? null
|
||||||
|
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
|
||||||
|
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
|
||||||
|
|
||||||
|
// Character chips — toggle while preserving the catalog order (stable output).
|
||||||
|
const selectedTraits = new Set(charTraits)
|
||||||
|
const toggleTrait = (key: string) => {
|
||||||
|
const next = new Set(selectedTraits)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
setCharTraits(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
|
}
|
||||||
|
|
||||||
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
|
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
if (gerbil.error || !gerbil.data) {
|
if (gerbil.error || !gerbil.data) {
|
||||||
return (
|
return (
|
||||||
@@ -91,161 +130,282 @@ export default function GerbilDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const g = gerbil.data
|
const g = gerbil.data
|
||||||
|
// Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege.
|
||||||
|
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
||||||
|
|
||||||
const geno = describeGenotype(g.genotype)
|
const geno = describeGenotype(g.genotype)
|
||||||
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||||
|
|
||||||
// Seed the Charakterbogen once from the loaded gerbil (adjust-state-during-render).
|
const parentLink = (
|
||||||
|
pid: string | null,
|
||||||
|
p: Parameters<typeof gerbilName>[0] | null,
|
||||||
|
): ReactNode =>
|
||||||
|
pid && p ? (
|
||||||
|
<Link to={`/rennmaeuse/${pid}`}>{gerbilName(p) || de.pages.gerbils.nameless}</Link>
|
||||||
|
) : (
|
||||||
|
de.pages.litters.detail.unknownParent
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seed the character state once from the loaded gerbil (adjust-state-during-render).
|
||||||
if (charInit !== g.id) {
|
if (charInit !== g.id) {
|
||||||
setCharInit(g.id)
|
setCharInit(g.id)
|
||||||
setCharTraits(g.characterTraits ?? [])
|
setCharTraits(g.characterTraits ?? [])
|
||||||
setCharNote(g.characterNote ?? '')
|
setCharNote(g.characterNote ?? '')
|
||||||
|
setCharExpanded(g.status === 'Breeding' || g.status === 'Pet')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="ak">
|
||||||
<header className="page-head">
|
<div className="ak-hero">
|
||||||
<div>
|
<GerbilProfilePhoto
|
||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
gerbilId={g.id}
|
||||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
className="ak-herophoto"
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
fallback={
|
||||||
|
<div className="ak-herophoto ak-herophoto--empty" aria-hidden="true">
|
||||||
|
🐭
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="ak-herobar">
|
||||||
|
<Link to="/rennmaeuse" className="ak-back" aria-label={t.detail.back}>
|
||||||
|
‹
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="ak-heroover">
|
||||||
|
<h1 className="ak-name">{gerbilName(g) || de.pages.gerbils.nameless}</h1>
|
||||||
|
<span className="ak-statusbadge" style={{ color: STATUS_COLORS[g.status] }}>
|
||||||
|
{statusLabel(g.status)}
|
||||||
|
</span>
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
<span className="ak-badge-extern" title={de.pages.gerbils.externalTitle}>
|
||||||
{de.pages.gerbils.externalBadge}
|
{de.pages.gerbils.externalBadge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
</div>
|
||||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
|
||||||
|
<div className="ak-content">
|
||||||
|
<div className="ak-facts">
|
||||||
|
<span className="ak-fact">
|
||||||
|
<span aria-hidden="true">{GENDER_SYMBOL[g.gender]}</span>
|
||||||
|
{genderLabel(g.gender)}
|
||||||
|
{g.isCastrated ? ' (Kastrat)' : ''}
|
||||||
|
</span>
|
||||||
|
{g.dateOfBirth && (
|
||||||
|
<span className="ak-fact">
|
||||||
|
<span aria-hidden="true">🎂</span>
|
||||||
|
{formatDate(g.dateOfBirth)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{storedColorName && (
|
||||||
|
<span className="ak-fact">
|
||||||
|
<span aria-hidden="true">🎨</span>
|
||||||
|
{storedColorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ak-actions">
|
||||||
|
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="ak-btn primary">
|
||||||
{t.detail.edit}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={`/genetik?parent=${g.id}`} className="btn">
|
<Link to={`/genetik?parent=${g.id}`} className="ak-btn">
|
||||||
{t.detail.testMating}
|
{t.detail.testMating}
|
||||||
</Link>
|
</Link>
|
||||||
{g.status !== 'ForSale' && (
|
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="ak-btn">
|
||||||
|
{de.pages.stammbaum.openButton}
|
||||||
|
</Link>
|
||||||
|
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="ak-btn"
|
||||||
disabled={forSale.pending}
|
disabled={forSale.pending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const r = await forSale.run()
|
const r = await forSale.run()
|
||||||
if (r.ok) gerbil.reload()
|
if (r.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
gerbil.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(r.error)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{de.pages.abgabe.markAction}
|
{de.pages.abgabe.markAction}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
|
|
||||||
{de.pages.stammbaum.openButton}
|
|
||||||
</Link>
|
|
||||||
{/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */}
|
|
||||||
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
|
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
|
||||||
<Link to={`/vertraege/neu?tiere=${g.id}`} className="btn">
|
<Link to={`/vertraege/neu?tiere=${g.id}`} className="ak-btn">
|
||||||
{de.pages.vertraege.wizard.title}
|
{de.pages.vertraege.wizard.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link to="/rennmaeuse" className="btn">
|
<Link to="/rennmaeuse" className="ak-btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<h3>{t.detail.masterData}</h3>
|
<section className="ak-card">
|
||||||
<dl className="def-list">
|
<h2 className="ak-h2">{t.detail.masterData}</h2>
|
||||||
<Row label={t.fields.gender} value={genderLabel(g.gender)} />
|
<dl className="ak-kvlist">
|
||||||
<Row label={t.fields.status} value={statusLabel(g.status)} />
|
<Kv label={t.fields.gender}>
|
||||||
<Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} />
|
{genderLabel(g.gender)}
|
||||||
|
{g.isCastrated && <span style={{ marginLeft: '0.4rem', opacity: 0.8 }}>(Kastrat)</span>}
|
||||||
|
</Kv>
|
||||||
|
<Kv label={t.fields.status}>{statusLabel(g.status)}</Kv>
|
||||||
|
<Kv label={t.fields.dateOfBirth}>{formatDate(g.dateOfBirth)}</Kv>
|
||||||
{g.status === 'Deceased' && (
|
{g.status === 'Deceased' && (
|
||||||
<>
|
<>
|
||||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
<Kv label={t.fields.dateOfDeath}>{formatDate(g.dateOfDeath)}</Kv>
|
||||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
|
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{g.status === 'GivenAway' && (
|
{g.status === 'GivenAway' && (
|
||||||
<Row label={t.fields.goHomeDate} value={formatDate(g.goHomeDate)} />
|
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
|
||||||
)}
|
)}
|
||||||
<Row
|
<Kv label={t.fields.colorVariety}>
|
||||||
label={t.fields.colorVariety}
|
{storedColorName ? (
|
||||||
value={
|
<>
|
||||||
storedColorName ? (
|
<FarbschlagImage name={storedColorName} size={22} />
|
||||||
<span className="farbschlag-value">
|
|
||||||
<FarbschlagImage name={storedColorName} size={32} />
|
|
||||||
{storedColorName}
|
{storedColorName}
|
||||||
</span>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'—'
|
'—'
|
||||||
)
|
)}
|
||||||
}
|
</Kv>
|
||||||
/>
|
{g.spottingType && (
|
||||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
<Kv label={t.fields.spottingType}>
|
||||||
<Row
|
{de.pages.gerbils.spottingTypes[
|
||||||
label={t.fields.litter}
|
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
|
||||||
value={
|
] || g.spottingType}
|
||||||
g.litterId && litterName.has(g.litterId)
|
</Kv>
|
||||||
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
)}
|
||||||
: lookup(litterName, g.litterId)
|
{showEnclosure && (
|
||||||
}
|
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
|
||||||
/>
|
)}
|
||||||
<Row
|
<Kv label={t.fields.litter}>
|
||||||
label={t.fields.origin}
|
{g.litterId && litterName.has(g.litterId) ? (
|
||||||
value={
|
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||||
g.originContactId && contactName.has(g.originContactId)
|
) : (
|
||||||
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
lookup(litterName, g.litterId)
|
||||||
: (g.originBreeder || null)
|
)}
|
||||||
}
|
</Kv>
|
||||||
/>
|
{ownLitter && (
|
||||||
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
|
||||||
<Row label={t.fields.notes} value={g.notes || null} />
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<h3>{t.detail.genetics}</h3>
|
|
||||||
{geno ? (
|
|
||||||
<dl className="def-list">
|
|
||||||
<Row label={t.fields.genotype} value={<code>{geno.display}</code>} />
|
|
||||||
<Row
|
|
||||||
label={t.detail.resolvedPrefix}
|
|
||||||
value={
|
|
||||||
<>
|
<>
|
||||||
|
<Kv label={t.detail.parentLittersRoleVater}>{parentLink(fatherId, father.data)}</Kv>
|
||||||
|
<Kv label={t.detail.parentLittersRoleMutter}>{parentLink(motherId, mother.data)}</Kv>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Kv label={t.fields.origin}>
|
||||||
|
{g.originContactId && contactName.has(g.originContactId) ? (
|
||||||
|
<Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
||||||
|
) : (
|
||||||
|
g.originBreeder || '—'
|
||||||
|
)}
|
||||||
|
</Kv>
|
||||||
|
{g.status !== 'Deceased' && (
|
||||||
|
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
|
||||||
|
)}
|
||||||
|
<Kv label={t.fields.notes}>{g.notes || '—'}</Kv>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ak-card">
|
||||||
|
<h2 className="ak-h2">{t.detail.genetics}</h2>
|
||||||
|
{geno ? (
|
||||||
|
<dl className="ak-kvlist">
|
||||||
|
<Kv label={t.fields.genotype}>
|
||||||
|
<code className="ak-genotype">{geno.display}</code>
|
||||||
|
</Kv>
|
||||||
|
<Kv label={t.detail.resolvedPrefix}>
|
||||||
{geno.farbschlag}
|
{geno.farbschlag}
|
||||||
{storedColorName &&
|
{storedColorName &&
|
||||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
||||||
storedColorName !== geno.farbschlag && (
|
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||||
<small className="mismatch-hint"> ⚠ {t.detail.farbschlagMismatch}</small>
|
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||||
)}
|
)}
|
||||||
</>
|
</Kv>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</dl>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">{t.detail.genotypeNotSet}</p>
|
<p className="ak-empty">{t.detail.genotypeNotSet}</p>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3>{de.character.sectionTitle}</h3>
|
<section className="ak-card">
|
||||||
<Charakterbogen
|
|
||||||
traits={charTraits}
|
|
||||||
note={charNote}
|
|
||||||
onTraitsChange={setCharTraits}
|
|
||||||
onNoteChange={setCharNote}
|
|
||||||
/>
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--primary"
|
className="ak-card-toggle"
|
||||||
|
aria-expanded={charExpanded}
|
||||||
|
onClick={() => setCharExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<h2 className="ak-h2">{de.character.sectionTitle}</h2>
|
||||||
|
<span className="ak-card-chev" aria-hidden="true">
|
||||||
|
{charExpanded ? '▴' : '▾'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{charExpanded && (
|
||||||
|
<>
|
||||||
|
{TRAIT_CATEGORIES.map((cat) => {
|
||||||
|
const count = cat.traits.filter((tr) => selectedTraits.has(tr.key)).length
|
||||||
|
return (
|
||||||
|
<div className="ak-cgroup" key={cat.category}>
|
||||||
|
<div className="ak-glabel">
|
||||||
|
{cat.category}
|
||||||
|
{count > 0 && <span className="ak-gcount">{count}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="ak-chips">
|
||||||
|
{cat.traits.map((tr) => {
|
||||||
|
const on = selectedTraits.has(tr.key)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tr.key}
|
||||||
|
type="button"
|
||||||
|
className={'ak-chip' + (on ? ' on' : '') + (tr.warn ? ' warn' : '')}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => toggleTrait(tr.key)}
|
||||||
|
>
|
||||||
|
{tr.warn && <span aria-hidden="true">⚠</span>}
|
||||||
|
{tr.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<textarea
|
||||||
|
className="ak-notes"
|
||||||
|
value={charNote}
|
||||||
|
placeholder={de.character.notePlaceholder}
|
||||||
|
onChange={(e) => setCharNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="ak-saverow">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ak-btn primary"
|
||||||
disabled={saveCharacter.pending}
|
disabled={saveCharacter.pending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const r = await saveCharacter.run()
|
const r = await saveCharacter.run()
|
||||||
if (r.ok) gerbil.reload()
|
if (r.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
gerbil.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(r.error)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{saveCharacter.pending ? t.form.saving : de.character.save}
|
{saveCharacter.pending ? t.form.saving : de.character.save}
|
||||||
</button>
|
</button>
|
||||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3>{t.detail.parentLittersTitle}</h3>
|
<section className="ak-card">
|
||||||
|
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
|
||||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||||
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
<p className="ak-empty">{t.detail.parentLittersEmpty}</p>
|
||||||
)}
|
)}
|
||||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
@@ -254,7 +414,9 @@ export default function GerbilDetailPage() {
|
|||||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{litter.name}</span>
|
<span className="gerbil-card__name">{litter.name}</span>
|
||||||
<span className="badge badge--active">
|
<span className="badge badge--active">
|
||||||
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
{litter.fatherId === id
|
||||||
|
? t.detail.parentLittersRoleVater
|
||||||
|
: t.detail.parentLittersRoleMutter}
|
||||||
</span>
|
</span>
|
||||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||||
{litter.totalBorn !== null && (
|
{litter.totalBorn !== null && (
|
||||||
@@ -267,27 +429,31 @@ export default function GerbilDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
<section className="ak-card">
|
||||||
<div className="tabs" role="tablist">
|
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
||||||
|
<div className="ak-tabs" role="tablist">
|
||||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === key}
|
aria-selected={tab === key}
|
||||||
className={tab === key ? 'tab tab--active' : 'tab'}
|
className={tab === key ? 'ak-tab active' : 'ak-tab'}
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
>
|
>
|
||||||
{t.detail.tabs[key]}
|
{t.detail.tabs[key]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="tab-panel" role="tabpanel">
|
<div className="ak-tabpanel" role="tabpanel">
|
||||||
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
||||||
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
||||||
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
|
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
|
||||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||||
import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
import { getBreederProfile } from '../api/settings'
|
||||||
|
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { genderLabel, statusLabel } from '../format/labels'
|
import { genderLabel, statusLabel } from '../format/labels'
|
||||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
|
||||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||||
|
import ColorVarietyPicker from '../components/ColorVarietyPicker'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
import '../components/NameSuggestPanel.css'
|
import '../components/NameSuggestPanel.css'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -29,11 +31,14 @@ interface FormState {
|
|||||||
notes: string
|
notes: string
|
||||||
isResident: boolean
|
isResident: boolean
|
||||||
isDeaf: boolean | null
|
isDeaf: boolean | null
|
||||||
|
spottingType: string
|
||||||
|
isSchecke: boolean
|
||||||
|
isCastrated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
name: '',
|
name: '',
|
||||||
gender: '',
|
gender: 'unknown',
|
||||||
status: 'Breeding',
|
status: 'Breeding',
|
||||||
dateOfBirth: '',
|
dateOfBirth: '',
|
||||||
dateOfDeath: '',
|
dateOfDeath: '',
|
||||||
@@ -49,6 +54,9 @@ const EMPTY: FormState = {
|
|||||||
notes: '',
|
notes: '',
|
||||||
isResident: true,
|
isResident: true,
|
||||||
isDeaf: null,
|
isDeaf: null,
|
||||||
|
spottingType: '',
|
||||||
|
isSchecke: false,
|
||||||
|
isCastrated: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -69,7 +77,11 @@ function formFromGerbil(g: {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
isResident?: boolean | null
|
isResident?: boolean | null
|
||||||
isDeaf?: boolean | null
|
isDeaf?: boolean | null
|
||||||
|
spottingType?: string | null
|
||||||
|
isCastrated?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
|
const hasSp = g.genotype?.includes('Sp') || false
|
||||||
|
const hasSpottingType = Boolean(g.spottingType)
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
gender: g.gender,
|
gender: g.gender,
|
||||||
@@ -88,6 +100,9 @@ function formFromGerbil(g: {
|
|||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
isResident: g.isResident ?? true,
|
isResident: g.isResident ?? true,
|
||||||
isDeaf: g.isDeaf ?? null,
|
isDeaf: g.isDeaf ?? null,
|
||||||
|
spottingType: g.spottingType ?? '',
|
||||||
|
isSchecke: hasSp || hasSpottingType,
|
||||||
|
isCastrated: g.isCastrated ?? false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +122,7 @@ function isGenotypeValid(genotype: string): boolean {
|
|||||||
export default function GerbilFormPage() {
|
export default function GerbilFormPage() {
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
@@ -120,6 +136,9 @@ export default function GerbilFormPage() {
|
|||||||
const enclosures = useApi(() => listEnclosures(), [])
|
const enclosures = useApi(() => listEnclosures(), [])
|
||||||
const contacts = useApi(() => listContacts(), [])
|
const contacts = useApi(() => listContacts(), [])
|
||||||
const litters = useApi(() => listLitters(), [])
|
const litters = useApi(() => listLitters(), [])
|
||||||
|
// Own Zucht is the default Herkunft (empty origin = own-bred); label it with the
|
||||||
|
// breeder profile's Zuchtname so new gerbils default to the own cattery.
|
||||||
|
const breederProfile = useApi(() => getBreederProfile(), [])
|
||||||
|
|
||||||
// Prefill from the loaded gerbil once (edit mode). React's "adjust state
|
// Prefill from the loaded gerbil once (edit mode). React's "adjust state
|
||||||
// during render" pattern — runs without an effect and re-renders immediately.
|
// during render" pattern — runs without an effect and re-renders immediately.
|
||||||
@@ -154,7 +173,9 @@ export default function GerbilFormPage() {
|
|||||||
function validate(): boolean {
|
function validate(): boolean {
|
||||||
const next: Partial<Record<keyof FormState, string>> = {}
|
const next: Partial<Record<keyof FormState, string>> = {}
|
||||||
if (form.name.trim() === '') next.name = t.validation.nameRequired
|
if (form.name.trim() === '') next.name = t.validation.nameRequired
|
||||||
if (form.gender === '') next.gender = t.validation.genderRequired
|
if (form.gender === 'unknown' || form.gender === '') {
|
||||||
|
next.gender = t.validation.genderRequired
|
||||||
|
}
|
||||||
if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid
|
if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid
|
||||||
if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
|
if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
|
||||||
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
|
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
|
||||||
@@ -184,17 +205,21 @@ export default function GerbilFormPage() {
|
|||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
isResident: form.isResident,
|
isResident: form.isResident,
|
||||||
isDeaf: form.isDeaf,
|
isDeaf: form.isDeaf,
|
||||||
|
spottingType: nn(form.spottingType),
|
||||||
|
isCastrated: form.isCastrated,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
// On failure mutation.error drives the inline alert; run() never throws.
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
|
||||||
const selectedColorName =
|
|
||||||
(colorVarieties.data ?? []).find((cv) => cv.id === form.colorVarietyId)?.name ?? null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||||
@@ -229,22 +254,180 @@ export default function GerbilFormPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.gender} *</span>
|
<span>{t.fields.origin}</span>
|
||||||
<select
|
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
|
||||||
value={form.gender}
|
<option value="">
|
||||||
onChange={(e) => set('gender', e.target.value as Gender | '')}
|
{breederProfile.data?.zuchtName?.trim()
|
||||||
aria-invalid={Boolean(errors.gender)}
|
? `${t.form.ownBreeding} — ${breederProfile.data.zuchtName.trim()}`
|
||||||
>
|
: t.form.ownBreeding}
|
||||||
<option value="">{t.form.none}</option>
|
</option>
|
||||||
{GENDERS.map((g) => (
|
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
|
||||||
<option key={g} value={g}>
|
<option key={c.id} value={c.id}>
|
||||||
{genderLabel(g)}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{errors.gender && <small className="error-text">{errors.gender}</small>}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.originBreeder}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.originBreeder}
|
||||||
|
placeholder="z. B. Zucht der Kleinen Chaoten"
|
||||||
|
onChange={(e) => set('originBreeder', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span>{t.fields.gender} *</span>
|
||||||
|
<div className="gender-selector-group">
|
||||||
|
{(['male', 'female'] as const).map((g) => {
|
||||||
|
const active = form.gender === g
|
||||||
|
let icon = '♂'
|
||||||
|
let colorClass = 'male'
|
||||||
|
if (g === 'female') {
|
||||||
|
icon = '♀'
|
||||||
|
colorClass = 'female'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
type="button"
|
||||||
|
className={`gender-btn gender-btn--${colorClass} ${active ? 'is-active' : ''}`}
|
||||||
|
onClick={() => set('gender', active ? 'unknown' : g)}
|
||||||
|
>
|
||||||
|
<span className="gender-btn__icon">{icon}</span>
|
||||||
|
<span className="gender-btn__label">{genderLabel(g)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{errors.gender && <small className="error-text">{errors.gender}</small>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field field--check">
|
||||||
|
<span>{t.fields.isCastrated}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isCastrated}
|
||||||
|
onChange={(e) => set('isCastrated', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.dateOfBirth}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={form.dateOfBirth}
|
||||||
|
onChange={(e) => set('dateOfBirth', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-group-box">
|
||||||
|
<div className="form-group-box__title">{de.pages.gerbils.form.colorSectionTitle}</div>
|
||||||
|
|
||||||
|
<div className="form-row-2col">
|
||||||
|
<div className="field">
|
||||||
|
<span>{t.fields.colorVariety}</span>
|
||||||
|
<ColorVarietyPicker
|
||||||
|
value={form.colorVarietyId}
|
||||||
|
colorVarieties={colorVarieties.data ?? []}
|
||||||
|
onChange={(newId) => {
|
||||||
|
set('colorVarietyId', newId)
|
||||||
|
if (newId) {
|
||||||
|
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
|
||||||
|
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field field--check">
|
||||||
|
<span>{de.pages.gerbils.form.isScheckeLabel}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isSchecke}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked
|
||||||
|
set('isSchecke', checked)
|
||||||
|
if (!checked) {
|
||||||
|
set('spottingType', '')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.isSchecke && (
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.spottingType}</span>
|
||||||
|
<select
|
||||||
|
value={form.spottingType}
|
||||||
|
onChange={(e) => set('spottingType', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{de.pages.gerbils.spottingTypes.none}</option>
|
||||||
|
{Object.keys(de.pages.gerbils.spottingTypes)
|
||||||
|
.filter((key) => key !== 'none')
|
||||||
|
.map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{de.pages.gerbils.spottingTypes[key as keyof typeof de.pages.gerbils.spottingTypes]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.genotype}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.genotype}
|
||||||
|
placeholder="Aa CC Dd EE GG Pp Spsp rere"
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
set('genotype', val)
|
||||||
|
if (val.includes('Sp')) {
|
||||||
|
set('isSchecke', true)
|
||||||
|
}
|
||||||
|
if (val.trim() !== '' && isGenotypeValid(val)) {
|
||||||
|
try {
|
||||||
|
const name = genotypeToFarbschlag(fromDisplayString(val))
|
||||||
|
if (name === UNKNOWN_FARBSCHLAG) {
|
||||||
|
set('colorVarietyId', '')
|
||||||
|
} else {
|
||||||
|
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
|
||||||
|
set('colorVarietyId', match?.id ?? '')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid while typing — leave colorVarietyId unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-invalid={Boolean(errors.genotype)}
|
||||||
|
/>
|
||||||
|
<small className={errors.genotype ? 'error-text' : 'muted'}>
|
||||||
|
{errors.genotype ?? t.form.genotypeHint}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.isDeaf}</span>
|
||||||
|
<select
|
||||||
|
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
set('isDeaf', v === '' ? null : v === 'true')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t.form.isDeafUnknown}</option>
|
||||||
|
<option value="true">{t.form.isDeafYes}</option>
|
||||||
|
<option value="false">{t.form.isDeafNo}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
|
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
|
||||||
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
|
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -269,16 +452,6 @@ export default function GerbilFormPage() {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.fields.dateOfBirth}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="input"
|
|
||||||
value={form.dateOfBirth}
|
|
||||||
onChange={(e) => set('dateOfBirth', e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{form.status === 'Deceased' && (
|
{form.status === 'Deceased' && (
|
||||||
<>
|
<>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -315,31 +488,8 @@ export default function GerbilFormPage() {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="field">
|
{/* Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege. */}
|
||||||
<span>{t.fields.colorVariety}</span>
|
{form.status !== 'GivenAway' && form.status !== 'Deceased' && (
|
||||||
<span className="farbschlag-value">
|
|
||||||
<select
|
|
||||||
value={form.colorVarietyId}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newId = e.target.value
|
|
||||||
set('colorVarietyId', newId)
|
|
||||||
if (newId) {
|
|
||||||
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
|
|
||||||
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{t.form.none}</option>
|
|
||||||
{(colorVarieties.data ?? []).map((cv) => (
|
|
||||||
<option key={cv.id} value={cv.id}>
|
|
||||||
{cv.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedColorName && <FarbschlagImage name={selectedColorName} size={36} />}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.enclosure}</span>
|
<span>{t.fields.enclosure}</span>
|
||||||
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
||||||
@@ -351,6 +501,7 @@ export default function GerbilFormPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.litter}</span>
|
<span>{t.fields.litter}</span>
|
||||||
@@ -364,27 +515,7 @@ export default function GerbilFormPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.fields.origin}</span>
|
|
||||||
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
|
|
||||||
<option value="">{t.form.none}</option>
|
|
||||||
{(contacts.data ?? []).map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.fields.originBreeder}</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.originBreeder}
|
|
||||||
placeholder="z. B. Zucht der Kleinen Chaoten"
|
|
||||||
onChange={(e) => set('originBreeder', e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.receiver}</span>
|
<span>{t.fields.receiver}</span>
|
||||||
@@ -393,7 +524,7 @@ export default function GerbilFormPage() {
|
|||||||
onChange={(e) => set('receiverContactId', e.target.value)}
|
onChange={(e) => set('receiverContactId', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{t.form.none}</option>
|
<option value="">{t.form.none}</option>
|
||||||
{(contacts.data ?? []).map((c) => (
|
{(contacts.data ?? []).filter((c) => c.isReceiver || c.id === form.receiverContactId).map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -401,55 +532,16 @@ export default function GerbilFormPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.fields.genotype}</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.genotype}
|
|
||||||
placeholder="Aa CC Dd EE GG Pp Spsp rere"
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value
|
|
||||||
set('genotype', val)
|
|
||||||
if (val.trim() !== '' && isGenotypeValid(val)) {
|
|
||||||
try {
|
|
||||||
const name = genotypeToFarbschlag(fromDisplayString(val))
|
|
||||||
if (name === UNKNOWN_FARBSCHLAG) {
|
|
||||||
set('colorVarietyId', '')
|
|
||||||
} else {
|
|
||||||
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
|
|
||||||
set('colorVarietyId', match?.id ?? '')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// invalid while typing — leave colorVarietyId unchanged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-invalid={Boolean(errors.genotype)}
|
|
||||||
/>
|
|
||||||
<small className={errors.genotype ? 'error-text' : 'muted'}>
|
|
||||||
{errors.genotype ?? t.form.genotypeHint}
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.notes}</span>
|
<span>{t.fields.notes}</span>
|
||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.fields.isDeaf}</span>
|
|
||||||
<select
|
|
||||||
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value
|
|
||||||
set('isDeaf', v === '' ? null : v === 'true')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{t.form.isDeafUnknown}</option>
|
|
||||||
<option value="true">{t.form.isDeafYes}</option>
|
|
||||||
<option value="false">{t.form.isDeafNo}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field field--check" title={t.form.isResidentHint}>
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
<span>{t.form.isResidentLabel}</span>
|
<span>{t.form.isResidentLabel}</span>
|
||||||
|
|||||||
Binary file not shown.
@@ -96,6 +96,17 @@ export default function KontaktDetailPage() {
|
|||||||
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
{deleteError && <div className="alert alert--error">{deleteError}</div>}
|
||||||
|
|
||||||
<dl className="def-list">
|
<dl className="def-list">
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>Rolle</dt>
|
||||||
|
<dd>
|
||||||
|
{[
|
||||||
|
c.isBreeder ? 'Züchter' : null,
|
||||||
|
c.isReceiver ? 'Abnehmer' : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{t.fields.email}</dt>
|
<dt>{t.fields.email}</dt>
|
||||||
<dd>{c.email ?? '—'}</dd>
|
<dd>{c.email ?? '—'}</dd>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { createContact, getContact, updateContact, type CreateContact } from '../api/contacts'
|
import { createContact, getContact, updateContact, type CreateContact } from '../api/contacts'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
@@ -12,9 +13,21 @@ interface FormState {
|
|||||||
phone: string
|
phone: string
|
||||||
address: string
|
address: string
|
||||||
notes: string
|
notes: string
|
||||||
|
isBreeder: boolean
|
||||||
|
isReceiver: boolean
|
||||||
|
nameSuffix: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' }
|
const EMPTY: FormState = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
notes: '',
|
||||||
|
isBreeder: false,
|
||||||
|
isReceiver: true,
|
||||||
|
nameSuffix: '',
|
||||||
|
}
|
||||||
|
|
||||||
/** "" -> null, sonst der Wert. */
|
/** "" -> null, sonst der Wert. */
|
||||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||||
@@ -22,6 +35,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
|||||||
export default function KontaktFormPage() {
|
export default function KontaktFormPage() {
|
||||||
const t = de.pages.kontakte
|
const t = de.pages.kontakte
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
@@ -40,6 +54,9 @@ export default function KontaktFormPage() {
|
|||||||
phone: existing.data.phone ?? '',
|
phone: existing.data.phone ?? '',
|
||||||
address: existing.data.address ?? '',
|
address: existing.data.address ?? '',
|
||||||
notes: existing.data.notes ?? '',
|
notes: existing.data.notes ?? '',
|
||||||
|
isBreeder: existing.data.isBreeder,
|
||||||
|
isReceiver: existing.data.isReceiver,
|
||||||
|
nameSuffix: existing.data.nameSuffix ?? '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +80,17 @@ export default function KontaktFormPage() {
|
|||||||
phone: nn(form.phone),
|
phone: nn(form.phone),
|
||||||
address: nn(form.address),
|
address: nn(form.address),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
|
isBreeder: form.isBreeder,
|
||||||
|
isReceiver: form.isReceiver,
|
||||||
|
nameSuffix: form.isBreeder ? nn(form.nameSuffix) : null,
|
||||||
})
|
})
|
||||||
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
if (result.ok) {
|
||||||
// On failure mutation.error drives the inline alert; run() never throws.
|
toast.success(de.common.saved)
|
||||||
|
navigate(`/kontakte/${result.value.id}`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
// On failure mutation.error also drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
@@ -116,6 +141,36 @@ export default function KontaktFormPage() {
|
|||||||
<small className="muted">{t.form.addressHint}</small>
|
<small className="muted">{t.form.addressHint}</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field field--check">
|
||||||
|
<span>{t.fields.isBreeder}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isBreeder}
|
||||||
|
onChange={(e) => set('isBreeder', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field field--check">
|
||||||
|
<span>{t.fields.isReceiver}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isReceiver}
|
||||||
|
onChange={(e) => set('isReceiver', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.isBreeder && (
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.nameSuffix}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.nameSuffix}
|
||||||
|
onChange={(e) => set('nameSuffix', e.target.value)}
|
||||||
|
/>
|
||||||
|
<small className="muted">{t.fields.nameSuffixHint}</small>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.notes}</span>
|
<span>{t.fields.notes}</span>
|
||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
|
|||||||
@@ -1,35 +1,82 @@
|
|||||||
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
|
/** FEAT-2: Kontakte-Liste — Quick-Chip-Filter (Rolle), Sticky-Header + Infinite Scroll. */
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { listContactsPaged } from '../api/contacts'
|
import { listContactsPaged } from '../api/contacts'
|
||||||
import { condition, type GridifyQuery } from '../api/gridify'
|
import { andFilter, condition } from '../api/gridify'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||||
|
import {
|
||||||
|
GerbilFilterBar,
|
||||||
|
ActiveFilterChips,
|
||||||
|
type FilterCategory,
|
||||||
|
} from '../components/GerbilFilterBar'
|
||||||
|
// Reuse the Rennmäuse list's sticky header + card styles for a consistent look.
|
||||||
|
import './gerbils.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export default function KontaktePage() {
|
export default function KontaktePage() {
|
||||||
const t = de.pages.kontakte
|
const t = de.pages.kontakte
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
// Role filter: 'breeder' (Züchter) / 'receiver' (Abnehmer); multi-select.
|
||||||
|
const [roles, setRoles] = useState<string[]>([])
|
||||||
|
// Sticky header: collapse the title block once scrolled (IntersectionObserver sentinel).
|
||||||
|
const [condensed, setCondensed] = useState(false)
|
||||||
|
const topSentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = topSentinelRef.current
|
||||||
|
if (!el) return
|
||||||
|
const io = new IntersectionObserver(([entry]) => setCondensed(!entry.isIntersecting))
|
||||||
|
io.observe(el)
|
||||||
|
return () => io.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filter = search.trim()
|
const categories: FilterCategory[] = [
|
||||||
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
{
|
||||||
: undefined
|
key: 'rolle',
|
||||||
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
|
label: t.filters.role,
|
||||||
|
options: [
|
||||||
|
{ value: 'breeder', label: t.filters.breeder },
|
||||||
|
{ value: 'receiver', label: t.filters.receiver },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const selected = { rolle: roles }
|
||||||
|
const toggleRole = (_key: string, value: string) =>
|
||||||
|
setRoles((r) => (r.includes(value) ? r.filter((v) => v !== value) : [...r, value]))
|
||||||
|
const removeRole = (_key: string, value: string) => setRoles((r) => r.filter((v) => v !== value))
|
||||||
|
const clearAll = () => setRoles([])
|
||||||
|
|
||||||
const contacts = useApi(() => listContactsPaged(query), [filter, page])
|
// Role conditions map to different fields, so they're OR'd into one group here
|
||||||
|
// (rather than via orGroup which assumes a single field).
|
||||||
|
const roleConds = [
|
||||||
|
roles.includes('breeder') && condition({ field: 'isBreeder', op: '==', value: true }),
|
||||||
|
roles.includes('receiver') && condition({ field: 'isReceiver', op: '==', value: true }),
|
||||||
|
].filter((c): c is string => Boolean(c))
|
||||||
|
const roleFilter =
|
||||||
|
roleConds.length === 0 ? '' : roleConds.length === 1 ? roleConds[0] : `(${roleConds.join('|')})`
|
||||||
|
const filter = andFilter(
|
||||||
|
search.trim() && condition({ field: 'name', op: 'contains', value: search.trim() }),
|
||||||
|
roleFilter,
|
||||||
|
)
|
||||||
|
|
||||||
const total = contacts.data?.totalCount ?? 0
|
const list = useInfiniteList(
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
(page) =>
|
||||||
const items = contacts.data?.items ?? []
|
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||||
|
filter,
|
||||||
|
)
|
||||||
|
const sentinelRef = useInfiniteSentinel(list)
|
||||||
|
const { items, total, hasMore, loading, loadingMore } = list
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page gerbils-page">
|
||||||
<header className="page-head">
|
<div ref={topSentinelRef} className="gerbils-scrolltop" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="gerbils-topbar">
|
||||||
|
<header className={`page-head gerbils-head${condensed ? ' gerbils-head--condensed' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
{contacts.data && (
|
{!loading && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{total} {t.countLabel}
|
{total} {t.countLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -40,40 +87,90 @@ export default function KontaktePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="filters">
|
<div className="rmf-searchbar">
|
||||||
|
<span className="rmf-search-ico" aria-hidden="true">
|
||||||
|
⌕
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="input"
|
className="rmf-searchfield"
|
||||||
placeholder={t.searchPlaceholder}
|
placeholder={t.searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
setSearch(e.target.value)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
aria-label={t.fields.name}
|
aria-label={t.fields.name}
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rmf-search-clear"
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
aria-label={de.filterBar.searchClear}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contacts.loading && <p className="muted">{de.common.loading}</p>}
|
<GerbilFilterBar categories={categories} selected={selected} onToggle={toggleRole} />
|
||||||
{contacts.error && (
|
</div>
|
||||||
|
|
||||||
|
<ActiveFilterChips
|
||||||
|
categories={categories}
|
||||||
|
selected={selected}
|
||||||
|
onRemove={removeRole}
|
||||||
|
onClearAll={clearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{list.error && (
|
||||||
<div className="alert alert--error">
|
<div className="alert alert--error">
|
||||||
<span>{contacts.error}</span>
|
<span>{list.error}</span>
|
||||||
<button type="button" className="btn" onClick={contacts.reload}>
|
<button type="button" className="btn" onClick={list.reload}>
|
||||||
{de.common.retry}
|
{de.common.retry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!contacts.loading && !contacts.error && items.length === 0 && (
|
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
|
||||||
<p className="muted">{t.empty}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
<li key={c.id}>
|
<li key={c.id} className="gerbil-row">
|
||||||
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{c.name}</span>
|
<span className="gerbil-card__name">
|
||||||
|
{c.name}
|
||||||
|
{c.isBreeder && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: '#e0f2fe',
|
||||||
|
color: '#0369a1',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.filters.breeder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.isReceiver && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: '#fef3c7',
|
||||||
|
color: '#b45309',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.filters.receiver}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="gerbil-card__meta">
|
<span className="gerbil-card__meta">
|
||||||
{[c.phone, c.email].filter(Boolean).join(' · ')}
|
{[c.phone, c.email].filter(Boolean).join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
@@ -83,28 +180,12 @@ export default function KontaktePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||||
<nav className="pager" aria-label="Seitennavigation">
|
{hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||||
<button
|
{loadingMore && (
|
||||||
type="button"
|
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||||
className="btn"
|
{de.common.loading}
|
||||||
disabled={page <= 1}
|
</p>
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
{de.common.previous}
|
|
||||||
</button>
|
|
||||||
<span className="muted">
|
|
||||||
{de.common.page} {page} {de.common.of} {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
{de.common.next}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
|
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
|
import { contractFileUrl, contractPdfUrl, deleteContract, listContracts } from '../api/contracts'
|
||||||
import { listContactsPaged } from '../api/contacts'
|
import { listContactsPaged } from '../api/contacts'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||||
import { formatDate } from '../format/labels'
|
import { formatDate } from '../format/labels'
|
||||||
import './vertragWizard.css'
|
import './vertragWizard.css'
|
||||||
|
|
||||||
@@ -16,12 +17,14 @@ function formatPrice(price: number): string {
|
|||||||
|
|
||||||
export default function VertraegeListPage() {
|
export default function VertraegeListPage() {
|
||||||
const t = de.pages.vertraege
|
const t = de.pages.vertraege
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
|
|
||||||
const contracts = useApi(
|
const contracts = useInfiniteList(
|
||||||
() => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc' }),
|
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
|
||||||
[page],
|
'contracts',
|
||||||
)
|
)
|
||||||
|
const sentinelRef = useInfiniteSentinel(contracts)
|
||||||
|
const { items, total: totalCount, loading } = contracts
|
||||||
|
|
||||||
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||||
const contactName = useMemo(
|
const contactName = useMemo(
|
||||||
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
|
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
|
||||||
@@ -35,8 +38,8 @@ export default function VertraegeListPage() {
|
|||||||
if (result.ok) contracts.reload()
|
if (result.ok) contracts.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contracts.loading) return <p className="muted">{de.common.loading}</p>
|
if (loading) return <p className="muted">{de.common.loading}</p>
|
||||||
if (contracts.error || !contracts.data) {
|
if (contracts.error) {
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
@@ -50,9 +53,6 @@ export default function VertraegeListPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { items, totalCount } = contracts.data
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
@@ -81,6 +81,9 @@ export default function VertraegeListPage() {
|
|||||||
{t.fields.handoverDate} {formatDate(c.handoverDate)}
|
{t.fields.handoverDate} {formatDate(c.handoverDate)}
|
||||||
</span>
|
</span>
|
||||||
<span className="head-actions">
|
<span className="head-actions">
|
||||||
|
<a className="btn btn--primary" href={contractPdfUrl(c)}>
|
||||||
|
{t.downloadPdf}
|
||||||
|
</a>
|
||||||
<a className="btn" href={contractFileUrl(c)}>
|
<a className="btn" href={contractFileUrl(c)}>
|
||||||
{t.download}
|
{t.download}
|
||||||
</a>
|
</a>
|
||||||
@@ -98,23 +101,12 @@ export default function VertraegeListPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||||
<nav className="pager" aria-label="Seitennavigation">
|
{contracts.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
||||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
{contracts.loadingMore && (
|
||||||
{de.common.previous}
|
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||||
</button>
|
{de.common.loading}
|
||||||
<span>
|
</p>
|
||||||
{de.common.page} {page} {de.common.of} {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
>
|
|
||||||
{de.common.next}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag
|
* serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag
|
||||||
* anlegen, Tiere auf „Abgegeben“ stellen (Abnehmer + Abgabedatum).
|
* anlegen, Tiere auf „Abgegeben“ stellen (Abnehmer + Abgabedatum).
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
@@ -16,6 +16,7 @@ import { createContact, listContactsPaged } from '../api/contacts'
|
|||||||
import { contractFileUrl, createContract, type SaleContract } from '../api/contracts'
|
import { contractFileUrl, createContract, type SaleContract } from '../api/contracts'
|
||||||
import { getBreederProfile, isBreederProfileComplete } from '../api/settings'
|
import { getBreederProfile, isBreederProfileComplete } from '../api/settings'
|
||||||
import { listColorVarieties } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
|
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel } from '../format/labels'
|
import { formatDate, genderLabel } from '../format/labels'
|
||||||
import './vertragWizard.css'
|
import './vertragWizard.css'
|
||||||
@@ -47,7 +48,7 @@ export default function VertragWizardPage() {
|
|||||||
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
|
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
|
||||||
|
|
||||||
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||||
const contactItems = useMemo(() => contacts.data?.items ?? [], [contacts.data])
|
const contactItems = useMemo(() => (contacts.data?.items ?? []).filter((c) => c.isReceiver), [contacts.data])
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
const needle = contactSearch.trim().toLowerCase()
|
const needle = contactSearch.trim().toLowerCase()
|
||||||
return needle === ''
|
return needle === ''
|
||||||
@@ -62,6 +63,8 @@ export default function VertragWizardPage() {
|
|||||||
email: newContact.email.trim() || null,
|
email: newContact.email.trim() || null,
|
||||||
phone: newContact.phone.trim() || null,
|
phone: newContact.phone.trim() || null,
|
||||||
address: newContact.address.trim() || null,
|
address: newContact.address.trim() || null,
|
||||||
|
isReceiver: true,
|
||||||
|
isBreeder: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
async function onCreateContact() {
|
async function onCreateContact() {
|
||||||
@@ -108,6 +111,11 @@ export default function VertragWizardPage() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/* Vertragsfoto je Tier: GerbilId → PhotoId ('' = bewusst kein Foto). */
|
||||||
|
const [animalPhotos, setAnimalPhotos] = useState<Record<string, string>>({})
|
||||||
|
const setAnimalPhoto = (gerbilId: string, photoId: string) =>
|
||||||
|
setAnimalPhotos((m) => ({ ...m, [gerbilId]: photoId }))
|
||||||
|
|
||||||
/* ── Schritt 3: Preis & Datum ── */
|
/* ── Schritt 3: Preis & Datum ── */
|
||||||
const [priceText, setPriceText] = useState('')
|
const [priceText, setPriceText] = useState('')
|
||||||
const [handoverDate, setHandoverDate] = useState(todayIso())
|
const [handoverDate, setHandoverDate] = useState(todayIso())
|
||||||
@@ -125,6 +133,12 @@ export default function VertragWizardPage() {
|
|||||||
price: parsePrice(priceText)!,
|
price: parsePrice(priceText)!,
|
||||||
handoverDate,
|
handoverDate,
|
||||||
contractDate: contractDate || null,
|
contractDate: contractDate || null,
|
||||||
|
// nur ausgewählte Tiere mit tatsächlich gewähltem Foto übermitteln
|
||||||
|
animalPhotos: Object.fromEntries(
|
||||||
|
[...selectedIds]
|
||||||
|
.map((id) => [id, animalPhotos[id]] as const)
|
||||||
|
.filter(([, pid]) => Boolean(pid)),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
async function onGenerate() {
|
async function onGenerate() {
|
||||||
@@ -337,6 +351,13 @@ export default function VertragWizardPage() {
|
|||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{selectedIds.has(g.id) && (
|
||||||
|
<ContractPhotoPicker
|
||||||
|
gerbilId={g.id}
|
||||||
|
chosen={animalPhotos[g.id]}
|
||||||
|
onChange={(photoId) => setAnimalPhoto(g.id, photoId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -448,3 +469,57 @@ export default function VertragWizardPage() {
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foto-Auswahl je Tier für den Vertrag. Lädt die Tierfotos lazy; Standard ist
|
||||||
|
* das Profilfoto (erstes nach SortOrder). '' = bewusst „Kein Foto“.
|
||||||
|
* Rendert nichts, wenn das Tier keine Fotos hat.
|
||||||
|
*/
|
||||||
|
function ContractPhotoPicker({
|
||||||
|
gerbilId,
|
||||||
|
chosen,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
gerbilId: string
|
||||||
|
chosen: string | undefined
|
||||||
|
onChange: (photoId: string) => void
|
||||||
|
}) {
|
||||||
|
const t = de.pages.vertraege.wizard
|
||||||
|
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
|
||||||
|
const list = useMemo(() => photos.data ?? [], [photos.data])
|
||||||
|
|
||||||
|
// Sobald Fotos da sind und noch nichts entschieden ist: Profilfoto vorauswählen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (list.length > 0 && chosen === undefined) {
|
||||||
|
const p = profilePhoto(list)
|
||||||
|
if (p) onChange(p.id)
|
||||||
|
}
|
||||||
|
}, [list, chosen, onChange])
|
||||||
|
|
||||||
|
if (photos.loading || list.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contract-photopick">
|
||||||
|
<span className="contract-photopick__label">{t.photoLabel}</span>
|
||||||
|
<div className="contract-photopick__thumbs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contract-photopick__thumb contract-photopick__thumb--none${chosen === '' ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
>
|
||||||
|
{t.photoNone}
|
||||||
|
</button>
|
||||||
|
{list.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`contract-photopick__thumb${chosen === p.id ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => onChange(p.id)}
|
||||||
|
>
|
||||||
|
<img src={photoSrc(p)} alt={p.caption ?? ''} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { listLitters } from '../api/litters'
|
import { listLitters } from '../api/litters'
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
|
||||||
import type { Litter } from '../api/types'
|
import type { Gerbil, Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
|
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||||
import { formatDate } from '../format/labels'
|
import { formatDate } from '../format/labels'
|
||||||
import { FilterPanel } from '../components/FilterPanel'
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
type Tab = 'litters' | 'pairs'
|
type Tab = 'litters' | 'pairs'
|
||||||
type SortKey = 'dateDesc' | 'dateAsc'
|
type SortKey = 'dateDesc' | 'dateAsc'
|
||||||
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc: 'date' }
|
// Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
|
||||||
|
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
|
||||||
|
|
||||||
function yearOptions(): number[] {
|
function yearOptions(): number[] {
|
||||||
const current = new Date().getFullYear()
|
const current = new Date().getFullYear()
|
||||||
return Array.from({ length: 16 }, (_, i) => current - i)
|
return Array.from({ length: 30 }, (_, i) => current - i)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Pair {
|
interface Pair {
|
||||||
@@ -47,27 +50,36 @@ function derivePairs(litters: Litter[]): Pair[] {
|
|||||||
export default function WuerfeListPage() {
|
export default function WuerfeListPage() {
|
||||||
const t = de.pages.litters
|
const t = de.pages.litters
|
||||||
const [tab, setTab] = useState<Tab>('litters')
|
const [tab, setTab] = useState<Tab>('litters')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const [year, setYear] = useState('')
|
const [year, setYear] = useState('')
|
||||||
const [sort, setSort] = useState<SortKey>('dateDesc')
|
const [sort, setSort] = useState<SortKey>('dateDesc')
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
|
|
||||||
// Parent names (shared by both tabs).
|
// Parent names (shared by both tabs) — keep the full gerbil so the Zucht suffix applies.
|
||||||
|
const gerbilName = useGerbilName()
|
||||||
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||||
const nameById = useMemo(() => {
|
const gerbilById = useMemo(() => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, Gerbil>()
|
||||||
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
|
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)
|
||||||
return map
|
return map
|
||||||
}, [gerbils.data])
|
}, [gerbils.data])
|
||||||
const parentName = (id: string | null) => (id ? (nameById.get(id) ?? t.detail.unknownParent) : '—')
|
const parentName = useCallback((id: string | null): string => {
|
||||||
|
if (!id) return '—'
|
||||||
|
const gp = gerbilById.get(id)
|
||||||
|
return gp ? gerbilName(gp) || de.pages.gerbils.nameless : t.detail.unknownParent
|
||||||
|
}, [gerbilById, gerbilName, t.detail.unknownParent])
|
||||||
|
|
||||||
// Litters tab (paged, filtered).
|
// Litters tab (paged, filtered).
|
||||||
const filter = andFilter(
|
const filter = andFilter(
|
||||||
|
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
|
||||||
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
|
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
|
||||||
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
|
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
|
||||||
)
|
)
|
||||||
const orderBy = SORT_ORDER_BY[sort]
|
const orderBy = SORT_ORDER_BY[sort]
|
||||||
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
const litters = useInfiniteList(
|
||||||
const litters = useApi(() => listLitters(query), [filter, orderBy, page])
|
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
|
||||||
|
`${filter}|${orderBy}`,
|
||||||
|
)
|
||||||
|
const littersSentinelRef = useInfiniteSentinel(litters)
|
||||||
|
|
||||||
// Pairs tab: all litters, grouped client-side.
|
// Pairs tab: all litters, grouped client-side.
|
||||||
const allLitters = useApi(
|
const allLitters = useApi(
|
||||||
@@ -76,9 +88,17 @@ export default function WuerfeListPage() {
|
|||||||
)
|
)
|
||||||
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
|
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
|
||||||
|
|
||||||
const total = litters.data?.totalCount ?? 0
|
// Search-enabled pairs list (client-side search by parent name)
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const filteredPairs = useMemo(() => {
|
||||||
const items = litters.data?.items ?? []
|
if (!search.trim()) return pairs
|
||||||
|
const term = search.toLowerCase()
|
||||||
|
return pairs.filter((p) =>
|
||||||
|
parentName(p.fatherId).toLowerCase().includes(term) ||
|
||||||
|
parentName(p.motherId).toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
}, [pairs, search, parentName])
|
||||||
|
|
||||||
|
const items = litters.items
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
@@ -113,17 +133,28 @@ export default function WuerfeListPage() {
|
|||||||
{tab === 'litters' && (
|
{tab === 'litters' && (
|
||||||
<>
|
<>
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
activeCount={year !== '' ? 1 : 0}
|
searchField={
|
||||||
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
<input
|
||||||
|
type="search"
|
||||||
|
className="input"
|
||||||
|
placeholder={t.searchPlaceholder}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
aria-label="Wurf suchen"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
|
||||||
|
onReset={() => {
|
||||||
|
setSearch('')
|
||||||
|
setYear('')
|
||||||
|
setSort('dateDesc')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filterYear}</span>
|
<span>{t.filterYear}</span>
|
||||||
<select
|
<select
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => {
|
onChange={(e) => setYear(e.target.value)}
|
||||||
setYear(e.target.value)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="">{t.allYears}</option>
|
<option value="">{t.allYears}</option>
|
||||||
{yearOptions().map((y) => (
|
{yearOptions().map((y) => (
|
||||||
@@ -174,39 +205,45 @@ export default function WuerfeListPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
||||||
<nav className="pager" aria-label={de.common.pageNav}>
|
{litters.hasMore && (
|
||||||
<button
|
<div ref={littersSentinelRef} aria-hidden="true" style={{ height: 1 }} />
|
||||||
type="button"
|
)}
|
||||||
className="btn"
|
{litters.loadingMore && (
|
||||||
disabled={page <= 1}
|
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
{de.common.loading}
|
||||||
>
|
</p>
|
||||||
{de.common.previous}
|
|
||||||
</button>
|
|
||||||
<span className="muted">
|
|
||||||
{de.common.page} {page} {de.common.of} {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
{de.common.next}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'pairs' && (
|
{tab === 'pairs' && (
|
||||||
<>
|
<>
|
||||||
|
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: '320px' }}
|
||||||
|
placeholder="Elternteil suchen …"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
aria-label="Elternteil suchen"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--secondary btn--sm"
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
>
|
||||||
|
{de.pages.gerbils.filters.reset}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
|
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{!allLitters.loading && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
|
{!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
|
||||||
{pairs.length > 0 && (
|
{filteredPairs.length > 0 && (
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{pairs.map((p) => (
|
{filteredPairs.map((p) => (
|
||||||
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
|
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">
|
<span className="gerbil-card__name">
|
||||||
{parentName(p.fatherId)} × {parentName(p.motherId)}
|
{parentName(p.fatherId)} × {parentName(p.motherId)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
|
|||||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||||
import BreedingResultView from '../components/BreedingResultView'
|
import BreedingResultView from '../components/BreedingResultView'
|
||||||
import GerbilIcon from '../components/GerbilIcon'
|
import GerbilIcon from '../components/GerbilIcon'
|
||||||
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
import './wuerfe.css'
|
import './wuerfe.css'
|
||||||
|
|
||||||
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
|
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
|
||||||
@@ -23,6 +24,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
|||||||
receiver: string | null
|
receiver: string | null
|
||||||
tJf: JuvenileFields
|
tJf: JuvenileFields
|
||||||
}) {
|
}) {
|
||||||
|
const gerbilName = useGerbilName()
|
||||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||||
const rawUrl = g.profilePhotoUrl
|
const rawUrl = g.profilePhotoUrl
|
||||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||||
@@ -46,7 +48,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="juvenile-body">
|
<div className="juvenile-body">
|
||||||
<span className="gerbil-card__name">{g.name}</span>
|
<span className="gerbil-card__name">{gerbilName(g)}</span>
|
||||||
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
|
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
|
||||||
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
|
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
|
||||||
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
|
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
|
||||||
@@ -60,6 +62,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
|||||||
|
|
||||||
export default function WurfDetailPage() {
|
export default function WurfDetailPage() {
|
||||||
const t = de.pages.litters
|
const t = de.pages.litters
|
||||||
|
const gerbilName = useGerbilName()
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
|
|
||||||
const litter = useApi(() => getLitter(id), [id])
|
const litter = useApi(() => getLitter(id), [id])
|
||||||
@@ -137,8 +140,8 @@ export default function WurfDetailPage() {
|
|||||||
|
|
||||||
const l = litter.data
|
const l = litter.data
|
||||||
const registered = juveniles.data?.totalCount ?? 0
|
const registered = juveniles.data?.totalCount ?? 0
|
||||||
const parentLink = (id: string | null, g: { name: string } | null) =>
|
const parentLink = (id: string | null, g: Gerbil | null) =>
|
||||||
id && g ? <Link to={`/rennmaeuse/${id}`}>{g.name}</Link> : t.detail.unknownParent
|
id && g ? <Link to={`/rennmaeuse/${id}`}>{gerbilName(g) || de.pages.gerbils.nameless}</Link> : t.detail.unknownParent
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createLitter, getLitter, updateLitter } from '../api/litters'
|
|||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import type { CreateLitter } from '../api/types'
|
import type { CreateLitter } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
import AnimalPicker from '../components/AnimalPicker'
|
import AnimalPicker from '../components/AnimalPicker'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -47,6 +48,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
|||||||
export default function WurfFormPage() {
|
export default function WurfFormPage() {
|
||||||
const t = de.pages.litters
|
const t = de.pages.litters
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
@@ -126,6 +128,7 @@ export default function WurfFormPage() {
|
|||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
navigate(`/wuerfe/${result.value.id}`)
|
navigate(`/wuerfe/${result.value.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
142
gerbil-manager-web/src/pages/farbkatalog.css
Normal file
142
gerbil-manager-web/src/pages/farbkatalog.css
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
.farbkatalog-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-search-bar {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
max-width: 36rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-search-bar .input {
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 48, 38, 0.04);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-search-bar .input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 6px rgba(59, 48, 38, 0.04);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 20px rgba(59, 48, 38, 0.08);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-media {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/11;
|
||||||
|
background: var(--color-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card:hover .farbschlag-card-img,
|
||||||
|
.farbkatalog-card:hover .farbkatalog-card-img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-media-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--color-accent-soft) 0%, #ecd0b9 100%);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-body {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-subtitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-genotype {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-card-genotype code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-accent);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbkatalog-empty-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.farbkatalog-grid {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,42 @@
|
|||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Back-to-top: floating round button, above the mobile action bar. */
|
||||||
|
.rmf-backtotop {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
||||||
|
z-index: 25;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(61, 46, 35, 0.12), 0 8px 22px rgba(61, 46, 35, 0.12);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: gerbilRowIn 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
transition: transform 0.12s cubic-bezier(0.22, 1, 0.36, 1), background 0.15s;
|
||||||
|
}
|
||||||
|
.rmf-backtotop:hover {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
.rmf-backtotop:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
/* Desktop has a sidebar, not a bottom bar — sit at the normal bottom inset. */
|
||||||
|
.rmf-backtotop {
|
||||||
|
bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ----------
|
/* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ----------
|
||||||
The whole header (title + search + filter pills + bar) sticks to the top; the list
|
The whole header (title + search + filter pills + bar) sticks to the top; the list
|
||||||
scrolls underneath. The title row collapses once a 1px sentinel above the header
|
scrolls underneath. The title row collapses once a 1px sentinel above the header
|
||||||
|
|||||||
415
gerbil-manager-web/src/pages/rennmausakte.css
Normal file
415
gerbil-manager-web/src/pages/rennmausakte.css
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
/* DESIGN (Rennmausakte.html): gerbil detail page — hero photo + card sections.
|
||||||
|
Navigation is handled by the app shell (sidebar/bottom bar), so the design's own
|
||||||
|
sidebar/drawer is intentionally dropped. Palette uses the app's design tokens. */
|
||||||
|
|
||||||
|
.ak {
|
||||||
|
--ak-panel: #efe6d4;
|
||||||
|
--ak-tan: #ece0c8;
|
||||||
|
--ak-warn: #c2702a;
|
||||||
|
--ak-danger: #a6433a;
|
||||||
|
--ak-kv-pad: 14px;
|
||||||
|
/* Cancel app-main's horizontal padding so the hero spans full width. app-main has
|
||||||
|
no top padding (see index.css), so the hero already sits flush at the top. */
|
||||||
|
margin: 0 -1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ak {
|
||||||
|
margin: 0 -2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hero ---------- */
|
||||||
|
.ak-hero {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ak-herophoto {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--ak-panel);
|
||||||
|
}
|
||||||
|
.ak-herophoto--empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
.ak-herobar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.ak-back {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(20, 12, 6, 0.42);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.ak-heroover {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 40px 18px 16px;
|
||||||
|
background: linear-gradient(to top, rgba(20, 12, 6, 0.82), rgba(20, 12, 6, 0.1) 72%, transparent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ak-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ak-statusbadge {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ak-badge-extern {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
color: #6b5d4b;
|
||||||
|
border: 1px dashed rgba(107, 93, 75, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ak-content {
|
||||||
|
max-width: 940px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 16px 40px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ak-herophoto {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
.ak-heroover {
|
||||||
|
padding: 56px 36px 22px;
|
||||||
|
}
|
||||||
|
.ak-name {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
.ak-content {
|
||||||
|
padding: 24px 36px 40px;
|
||||||
|
}
|
||||||
|
/* On desktop the app sidebar provides navigation; the hero back arrow is redundant
|
||||||
|
(the action row has "Zurück zur Liste"). */
|
||||||
|
.ak-herobar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Quick facts ---------- */
|
||||||
|
.ak-facts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ak-fact {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Actions ---------- */
|
||||||
|
.ak-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
.ak-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 11px 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 2px rgba(61, 46, 35, 0.05);
|
||||||
|
transition: transform 0.1s, filter 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.ak-btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.ak-btn:hover {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
.ak-btn.primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.ak-btn.primary:hover {
|
||||||
|
filter: brightness(1.06);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
.ak-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Cards ---------- */
|
||||||
|
.ak-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-top: 14px;
|
||||||
|
box-shadow: 0 1px 3px rgba(61, 46, 35, 0.06);
|
||||||
|
}
|
||||||
|
.ak-h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible card header (e.g. Charakter) — full-width toggle button. */
|
||||||
|
.ak-card-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ak-card-toggle .ak-h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ak-card-chev {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Key/Value list (semantic dl/dt/dd, styled as a grid) ---------- */
|
||||||
|
.ak-kvlist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ak-kvlist dt,
|
||||||
|
.ak-kvlist dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ak-kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(96px, 32%) 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--ak-kv-pad) 2px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.ak-kv:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.ak-kv-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.ak-kv-value {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ak-kv-value a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.ak-genotype {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
background: var(--ak-tan);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 6px 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.ak-mismatch {
|
||||||
|
color: var(--ak-warn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Character chips ---------- */
|
||||||
|
.ak-cgroup {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.ak-cgroup:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.ak-glabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ak-gcount {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.ak-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.ak-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.14s
|
||||||
|
cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.ak-chip:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.ak-chip.on {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ak-chip.warn {
|
||||||
|
border-color: color-mix(in srgb, var(--ak-warn) 45%, var(--color-border));
|
||||||
|
color: var(--ak-warn);
|
||||||
|
}
|
||||||
|
.ak-chip.warn.on {
|
||||||
|
background: var(--ak-warn);
|
||||||
|
border-color: var(--ak-warn);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ak-notes {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 78px;
|
||||||
|
resize: vertical;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-top: 14px;
|
||||||
|
background: #fbf7ef;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.ak-notes:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.ak-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tabs ---------- */
|
||||||
|
.ak-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 26px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.ak-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ak-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 2px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ak-tab.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.ak-tabpanel {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Character save row */
|
||||||
|
.ak-saverow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
@@ -140,3 +140,50 @@
|
|||||||
grid-row: 1 / span 2;
|
grid-row: 1 / span 2;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Vertragsfoto-Auswahl je Tier (Schritt „Tiere“) ── */
|
||||||
|
.contract-photopick {
|
||||||
|
margin: 0.5rem 0 0.25rem 1.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.contract-photopick__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted, #8c7f6e);
|
||||||
|
}
|
||||||
|
.contract-photopick__thumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.contract-photopick__thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid var(--color-border, #d9ccb4);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.contract-photopick__thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.contract-photopick__thumb--none {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-muted, #8c7f6e);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.contract-photopick__thumb.is-selected {
|
||||||
|
border-color: var(--color-accent, #a85f2e);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent, #a85f2e) 30%, transparent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const de = {
|
|||||||
help: 'Hilfe',
|
help: 'Hilfe',
|
||||||
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
|
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
|
||||||
website: 'Webseite',
|
website: 'Webseite',
|
||||||
|
colorCatalog: 'Farbkatalog',
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
gerbils: {
|
gerbils: {
|
||||||
@@ -46,6 +47,8 @@ export const de = {
|
|||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
|
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
|
||||||
born: 'geb.',
|
born: 'geb.',
|
||||||
|
// Zurück-nach-oben-Button (erscheint beim Scrollen der langen Liste).
|
||||||
|
backToTop: 'Nach oben',
|
||||||
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
|
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
|
||||||
externalBadge: 'Extern',
|
externalBadge: 'Extern',
|
||||||
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
|
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
|
||||||
@@ -63,6 +66,9 @@ export const de = {
|
|||||||
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
|
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
|
||||||
showExternalShort: 'Externe Ahnen',
|
showExternalShort: 'Externe Ahnen',
|
||||||
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
|
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
|
||||||
|
// BESTAND-FILTER: Tiere ohne Geburtsdatum ausblenden (Toggle-Chip neben „Externe Ahnen“).
|
||||||
|
hideUndatedShort: 'Mit Geburtsdatum',
|
||||||
|
hideUndatedHint: 'Tiere ohne hinterlegtes Geburtsdatum ausblenden.',
|
||||||
},
|
},
|
||||||
// Sortier-Optionen
|
// Sortier-Optionen
|
||||||
sort: {
|
sort: {
|
||||||
@@ -88,7 +94,9 @@ export const de = {
|
|||||||
isDeaf: 'Gehörlos',
|
isDeaf: 'Gehörlos',
|
||||||
receiver: 'Abnehmer',
|
receiver: 'Abnehmer',
|
||||||
genotype: 'Genotyp',
|
genotype: 'Genotyp',
|
||||||
|
isCastrated: 'Kastrat',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
|
spottingType: 'Scheckungsart',
|
||||||
},
|
},
|
||||||
// Detailseite
|
// Detailseite
|
||||||
detail: {
|
detail: {
|
||||||
@@ -120,6 +128,8 @@ export const de = {
|
|||||||
createTitle: 'Neue Rennmaus anlegen',
|
createTitle: 'Neue Rennmaus anlegen',
|
||||||
editTitle: 'Rennmaus bearbeiten',
|
editTitle: 'Rennmaus bearbeiten',
|
||||||
none: '— keine Angabe —',
|
none: '— keine Angabe —',
|
||||||
|
// Herkunft-Standardoption: eigene Zucht (leere Herkunft = selbst gezüchtet).
|
||||||
|
ownBreeding: 'Eigene Zucht',
|
||||||
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)
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
@@ -128,6 +138,8 @@ export const de = {
|
|||||||
isDeafUnknown: 'Unbekannt',
|
isDeafUnknown: 'Unbekannt',
|
||||||
isDeafYes: 'Ja',
|
isDeafYes: 'Ja',
|
||||||
isDeafNo: 'Nein',
|
isDeafNo: 'Nein',
|
||||||
|
isScheckeLabel: 'Ist eine Schecke',
|
||||||
|
colorSectionTitle: 'Farbe & Genetik',
|
||||||
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
|
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
|
||||||
statusDerivedHint: {
|
statusDerivedHint: {
|
||||||
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
|
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
|
||||||
@@ -158,6 +170,17 @@ export const de = {
|
|||||||
GivenAway: 'Abgegeben',
|
GivenAway: 'Abgegeben',
|
||||||
ForSale: 'Zur Abgabe',
|
ForSale: 'Zur Abgabe',
|
||||||
},
|
},
|
||||||
|
spottingTypes: {
|
||||||
|
none: '— keine Scheckungsart —',
|
||||||
|
Punktschecke: 'Punktschecke',
|
||||||
|
Kragenschecke: 'Kragenschecke',
|
||||||
|
Ansatzschecke: 'Ansatzschecke',
|
||||||
|
Starkschecke: 'Starkschecke',
|
||||||
|
Superschecke: 'Superschecke',
|
||||||
|
'DP (Dark Patch)': 'DP (Dark Patch)',
|
||||||
|
WP: 'Minimalschecke (WP)',
|
||||||
|
wp: 'WP',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ──
|
// ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ──
|
||||||
litters: {
|
litters: {
|
||||||
@@ -170,6 +193,7 @@ export const de = {
|
|||||||
newButton: 'Neuer Wurf',
|
newButton: 'Neuer Wurf',
|
||||||
empty: 'Keine Würfe gefunden.',
|
empty: 'Keine Würfe gefunden.',
|
||||||
countLabel: 'Würfe',
|
countLabel: 'Würfe',
|
||||||
|
searchPlaceholder: 'Wurf suchen …',
|
||||||
filterYear: 'Jahr',
|
filterYear: 'Jahr',
|
||||||
allYears: 'Alle Jahre',
|
allYears: 'Alle Jahre',
|
||||||
sort: {
|
sort: {
|
||||||
@@ -239,6 +263,12 @@ export const de = {
|
|||||||
title: 'Züchter',
|
title: 'Züchter',
|
||||||
comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.',
|
comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.',
|
||||||
},
|
},
|
||||||
|
farbkatalog: {
|
||||||
|
title: 'Farbkatalog',
|
||||||
|
subtitle: 'Übersicht aller {count} registrierten Farbschläge und deren genetischen Formeln.',
|
||||||
|
searchPlaceholder: 'Nach Name, englischem Begriff oder Genen suchen (z. B. \'Zobel\', \'dd\', \'ee\', \'pp\')...',
|
||||||
|
empty: 'Keine Farbschläge gefunden, die Ihrer Suche entsprechen.',
|
||||||
|
},
|
||||||
notFound: {
|
notFound: {
|
||||||
title: 'Seite nicht gefunden',
|
title: 'Seite nicht gefunden',
|
||||||
message: 'Die angeforderte Seite existiert nicht.',
|
message: 'Die angeforderte Seite existiert nicht.',
|
||||||
@@ -335,6 +365,8 @@ export const de = {
|
|||||||
name: 'Name',
|
name: 'Name',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
},
|
},
|
||||||
|
// Bilder-Sektion auf der Gehege-Detailseite.
|
||||||
|
photosTitle: 'Bilder',
|
||||||
occupancy: {
|
occupancy: {
|
||||||
title: 'Bewohner',
|
title: 'Bewohner',
|
||||||
empty: 'Dieses Gehege ist derzeit leer.',
|
empty: 'Dieses Gehege ist derzeit leer.',
|
||||||
@@ -422,7 +454,8 @@ export const de = {
|
|||||||
newButton: 'Neuer Vertrag',
|
newButton: 'Neuer Vertrag',
|
||||||
empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.',
|
empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.',
|
||||||
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
|
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
|
||||||
download: 'Herunterladen',
|
download: 'Word',
|
||||||
|
downloadPdf: 'PDF',
|
||||||
delete: 'Löschen',
|
delete: 'Löschen',
|
||||||
confirmDelete:
|
confirmDelete:
|
||||||
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
|
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
|
||||||
@@ -454,6 +487,8 @@ export const de = {
|
|||||||
pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.',
|
pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.',
|
||||||
noAnimals: 'Keine abgebbaren Tiere gefunden.',
|
noAnimals: 'Keine abgebbaren Tiere gefunden.',
|
||||||
animalsRequired: 'Bitte mindestens ein Tier auswählen.',
|
animalsRequired: 'Bitte mindestens ein Tier auswählen.',
|
||||||
|
photoLabel: 'Foto für den Vertrag',
|
||||||
|
photoNone: 'Kein Foto',
|
||||||
// Schritt 3
|
// Schritt 3
|
||||||
priceLabel: 'Kaufpreis (€)',
|
priceLabel: 'Kaufpreis (€)',
|
||||||
pricePlaceholder: 'z. B. 72,00',
|
pricePlaceholder: 'z. B. 72,00',
|
||||||
@@ -486,6 +521,9 @@ export const de = {
|
|||||||
'Noch unvollständig: Name, Adresse und Ort werden für den Vertrag benötigt.',
|
'Noch unvollständig: Name, Adresse und Ort werden für den Vertrag benötigt.',
|
||||||
fields: {
|
fields: {
|
||||||
zuchtName: 'Zuchtname',
|
zuchtName: 'Zuchtname',
|
||||||
|
nameSuffix: 'Namens-Anhängsel der Zucht',
|
||||||
|
nameSuffixHint:
|
||||||
|
'Wird wie ein „Nachname" an eigene Tiere angehängt, z. B. „von den kleinen Chaoten“ → „Danako von den kleinen Chaoten“.',
|
||||||
name: 'Vor- und Nachname',
|
name: 'Vor- und Nachname',
|
||||||
nameHint: 'Mit Anrede, z. B. „Frau Erika Muster“.',
|
nameHint: 'Mit Anrede, z. B. „Frau Erika Muster“.',
|
||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
@@ -562,6 +600,12 @@ export const de = {
|
|||||||
empty: 'Keine Kontakte gefunden.',
|
empty: 'Keine Kontakte gefunden.',
|
||||||
countLabel: 'Kontakte',
|
countLabel: 'Kontakte',
|
||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
|
// Quick-Chip-Filter nach Rolle (Züchter / Abnehmer).
|
||||||
|
filters: {
|
||||||
|
role: 'Rolle',
|
||||||
|
breeder: 'Züchter',
|
||||||
|
receiver: 'Abnehmer',
|
||||||
|
},
|
||||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt
|
||||||
// (DATA-2-Schema: email/phone/address) — gebraucht für den Käufer-Block der Verträge.
|
// (DATA-2-Schema: email/phone/address) — gebraucht für den Käufer-Block der Verträge.
|
||||||
fields: {
|
fields: {
|
||||||
@@ -570,6 +614,11 @@ export const de = {
|
|||||||
phone: 'Telefon',
|
phone: 'Telefon',
|
||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
|
isBreeder: 'Ist Züchter (Zucht)',
|
||||||
|
isReceiver: 'Ist Abnehmer (Käufer)',
|
||||||
|
nameSuffix: 'Namens-Anhängsel der Zucht',
|
||||||
|
nameSuffixHint:
|
||||||
|
'Wird an Tiere aus dieser Zucht angehängt, z. B. „von den Wüstenwinden“.',
|
||||||
},
|
},
|
||||||
linked: {
|
linked: {
|
||||||
title: 'Verknüpfte Tiere',
|
title: 'Verknüpfte Tiere',
|
||||||
@@ -795,6 +844,10 @@ export const de = {
|
|||||||
delete: 'Löschen',
|
delete: 'Löschen',
|
||||||
confirmDelete: 'Wirklich löschen?',
|
confirmDelete: 'Wirklich löschen?',
|
||||||
pageNav: 'Seitennavigation',
|
pageNav: 'Seitennavigation',
|
||||||
|
// Toast-Rückmeldungen
|
||||||
|
saved: 'Gespeichert',
|
||||||
|
saveFailed: 'Speichern fehlgeschlagen',
|
||||||
|
deleted: 'Gelöscht',
|
||||||
},
|
},
|
||||||
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
|
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
|
||||||
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
||||||
|
|||||||
@@ -213,6 +213,35 @@
|
|||||||
"genotype": "AA CC DD ee Gg pp Spsp",
|
"genotype": "AA CC DD ee Gg pp Spsp",
|
||||||
"farbschlag": "Goldfuchs Starkschecke",
|
"farbschlag": "Goldfuchs Starkschecke",
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
|
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Joghurt von Privat",
|
||||||
|
"dob": "06.09.2013",
|
||||||
|
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
|
||||||
|
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
|
||||||
|
"source": "Julian 2026-06-12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ken'ichi",
|
||||||
|
"dob": "01.03.2015",
|
||||||
|
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
|
||||||
|
"genotype": "AA CC DD Ee GG PP spsp",
|
||||||
|
"farbschlag": "Agouti",
|
||||||
|
"source": "Julian 2026-06-12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Harumi",
|
||||||
|
"dob": "21.02.2015",
|
||||||
|
"decision": "Death date is 11.01.2018 (died in 2018).",
|
||||||
|
"dateOfDeath": "11.01.2018",
|
||||||
|
"source": "Julian 2026-06-12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
|
||||||
|
"dob": "18.05.2016",
|
||||||
|
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
|
||||||
|
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
|
||||||
|
"source": "Julian 2026-06-12"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,19 +35,20 @@ DEATH = re.compile(r"\+\s?(\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2})|\d{4})")
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- helpers ----
|
# ---------------------------------------------------------------- helpers ----
|
||||||
def gen_of(colnum):
|
def gen_of(colnum, offset=0):
|
||||||
"""Map a column number to a generation band (0=proband ... 5=deepest)."""
|
"""Map a column number to a generation band (0=proband ... 5=deepest)."""
|
||||||
if colnum <= 6:
|
effective_col = colnum - offset
|
||||||
return 0 # E band (proband / "Kids")
|
if effective_col <= 3:
|
||||||
if colnum <= 9:
|
return 0 # Column B (2) -> proband
|
||||||
return 1 # H band (parents)
|
if effective_col <= 6:
|
||||||
if colnum <= 12:
|
return 1 # Column E (5) -> parents
|
||||||
return 2 # K band (grandparents)
|
if effective_col <= 9:
|
||||||
if colnum <= 15:
|
return 2 # Column H (8) -> grandparents
|
||||||
return 3 # N band (great-grandparents)
|
if effective_col <= 12:
|
||||||
if colnum <= 17:
|
return 3 # Column K (11) -> great-grandparents
|
||||||
return 4 # Q band (gg-grandparents)
|
if effective_col <= 15:
|
||||||
return 5 # R/S band (name-pairs)
|
return 4 # Column N (14) -> gg-grandparents
|
||||||
|
return 5 # Column Q (17) or deeper -> ggg-grandparents
|
||||||
|
|
||||||
|
|
||||||
def norm_name(name):
|
def norm_name(name):
|
||||||
@@ -189,6 +190,9 @@ def extract_stammbaum(path):
|
|||||||
cells = xu.read_cells(z, sheets[0], ss)
|
cells = xu.read_cells(z, sheets[0], ss)
|
||||||
fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female)
|
fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female)
|
||||||
|
|
||||||
|
has_col2 = any(c == 2 for (c, r) in cells)
|
||||||
|
col_offset = 0 if has_col2 else 3
|
||||||
|
|
||||||
# group cells by column for block reconstruction
|
# group cells by column for block reconstruction
|
||||||
by_col = {}
|
by_col = {}
|
||||||
for (c, r), t in cells.items():
|
for (c, r), t in cells.items():
|
||||||
@@ -207,6 +211,7 @@ def extract_stammbaum(path):
|
|||||||
if not compact and not is_block_dob:
|
if not compact and not is_block_dob:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
name_row = r
|
||||||
if compact:
|
if compact:
|
||||||
name = clean_name(compact.group(1))
|
name = clean_name(compact.group(1))
|
||||||
dob, death, geno = parse_detail(t)
|
dob, death, geno = parse_detail(t)
|
||||||
@@ -221,6 +226,7 @@ def extract_stammbaum(path):
|
|||||||
if (c, rr) in cells and not re.match(r"^\*?\s?\d", cells[(c, rr)]) \
|
if (c, rr) in cells and not re.match(r"^\*?\s?\d", cells[(c, rr)]) \
|
||||||
and not gt.looks_like_genotype(cells[(c, rr)]):
|
and not gt.looks_like_genotype(cells[(c, rr)]):
|
||||||
name = clean_name(cells[(c, rr)])
|
name = clean_name(cells[(c, rr)])
|
||||||
|
name_row = rr
|
||||||
used.add((c, rr))
|
used.add((c, rr))
|
||||||
break
|
break
|
||||||
farbschlag = ""
|
farbschlag = ""
|
||||||
@@ -230,7 +236,7 @@ def extract_stammbaum(path):
|
|||||||
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
|
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
|
||||||
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
|
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
|
||||||
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
|
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
|
||||||
deep_band = gen_of(c) >= 2
|
deep_band = gen_of(c, col_offset) >= 2
|
||||||
for rr in range(r + 1, r + 4):
|
for rr in range(r + 1, r + 4):
|
||||||
cell = cells.get((c, rr))
|
cell = cells.get((c, rr))
|
||||||
if not cell:
|
if not cell:
|
||||||
@@ -269,9 +275,9 @@ def extract_stammbaum(path):
|
|||||||
"parentRefs": [],
|
"parentRefs": [],
|
||||||
"photos": [],
|
"photos": [],
|
||||||
"sourceFiles": [fname],
|
"sourceFiles": [fname],
|
||||||
"_gen": gen_of(c),
|
"_gen": gen_of(c, col_offset),
|
||||||
"_col": c,
|
"_col": c,
|
||||||
"_row": r,
|
"_row": name_row,
|
||||||
"_file": fname,
|
"_file": fname,
|
||||||
"_zucht": norm_zucht(zraw),
|
"_zucht": norm_zucht(zraw),
|
||||||
})
|
})
|
||||||
@@ -280,7 +286,7 @@ def extract_stammbaum(path):
|
|||||||
for (c, r), t in cells.items():
|
for (c, r), t in cells.items():
|
||||||
if (c, r) in used:
|
if (c, r) in used:
|
||||||
continue
|
continue
|
||||||
if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c) >= 4:
|
if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c, col_offset) >= 4:
|
||||||
for part in t.split(" & "):
|
for part in t.split(" & "):
|
||||||
part = clean_name(part)
|
part = clean_name(part)
|
||||||
if part:
|
if part:
|
||||||
@@ -291,7 +297,7 @@ def extract_stammbaum(path):
|
|||||||
"genotype": gt.parse(""), "deaf": None, "tags": [],
|
"genotype": gt.parse(""), "deaf": None, "tags": [],
|
||||||
"breeder": "", "zucht": zraw,
|
"breeder": "", "zucht": zraw,
|
||||||
"parentRefs": [], "photos": [], "sourceFiles": [fname],
|
"parentRefs": [], "photos": [], "sourceFiles": [fname],
|
||||||
"_gen": gen_of(c), "_col": c, "_row": r, "_file": fname,
|
"_gen": gen_of(c, col_offset), "_col": c, "_row": r, "_file": fname,
|
||||||
"_zucht": norm_zucht(zraw),
|
"_zucht": norm_zucht(zraw),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -319,9 +325,10 @@ def _reconstruct_parents(animals):
|
|||||||
father = above[-1] if above else None
|
father = above[-1] if above else None
|
||||||
mother = below[0] if below else None
|
mother = below[0] if below else None
|
||||||
for parent, role in ((father, "father"), (mother, "mother")):
|
for parent, role in ((father, "father"), (mother, "mother")):
|
||||||
if parent and parent["name"]:
|
if parent:
|
||||||
|
p_name = parent["name"] or "unbekannt"
|
||||||
a["parentRefs"].append({
|
a["parentRefs"].append({
|
||||||
"name": parent["name"],
|
"name": p_name,
|
||||||
"dob": parent["dob"],
|
"dob": parent["dob"],
|
||||||
"roleGuess": role,
|
"roleGuess": role,
|
||||||
"method": "chart-position",
|
"method": "chart-position",
|
||||||
@@ -337,13 +344,14 @@ def _attach_photos(z, sheets, animals, fname):
|
|||||||
for a in animals:
|
for a in animals:
|
||||||
by_gen.setdefault(a["_gen"], []).append(a)
|
by_gen.setdefault(a["_gen"], []).append(a)
|
||||||
media_dir = os.path.join(OUT, "photos")
|
media_dir = os.path.join(OUT, "photos")
|
||||||
|
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
|
||||||
for i, (sp, col, row, media) in enumerate(anchors):
|
for i, (sp, col, row, media) in enumerate(anchors):
|
||||||
g = gen_of(col)
|
g = gen_of(col, col_offset)
|
||||||
cands = by_gen.get(g, [])
|
cands = by_gen.get(g, [])
|
||||||
if not cands:
|
if not cands:
|
||||||
# fall back to nearest animal by row across all gens
|
# fall back to nearest animal by row across all gens
|
||||||
cands = animals
|
cands = animals
|
||||||
target = min(cands, key=lambda a: abs(a["_row"] - row)) if cands else None
|
target = min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None
|
||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
ext = os.path.splitext(media)[1] or ".img"
|
ext = os.path.splitext(media)[1] or ".img"
|
||||||
@@ -1026,6 +1034,9 @@ def main():
|
|||||||
print(f" {len(got):4d} {os.path.basename(path)}")
|
print(f" {len(got):4d} {os.path.basename(path)}")
|
||||||
raw_animals.extend(got)
|
raw_animals.extend(got)
|
||||||
|
|
||||||
|
# Skip pseudo-animal records (like "DD-Tumor bei Geschwister") that are actually notes
|
||||||
|
raw_animals = [a for a in raw_animals if "DD-Tumor" not in a["name"]]
|
||||||
|
|
||||||
litters = []
|
litters = []
|
||||||
if os.path.isfile(args.wurfchronik):
|
if os.path.isfile(args.wurfchronik):
|
||||||
litters = extract_wurfchronik(args.wurfchronik)
|
litters = extract_wurfchronik(args.wurfchronik)
|
||||||
|
|||||||
@@ -68,11 +68,18 @@ def _sls_alleles(token):
|
|||||||
|
|
||||||
|
|
||||||
def _deaf_value(token):
|
def _deaf_value(token):
|
||||||
"""dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None. Case-sensitive for Dea/dea."""
|
"""dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None."""
|
||||||
t = token.strip("()[]")
|
t = token.strip("()[]")
|
||||||
if t == "dea" or t.lower() == "taub":
|
t_lower = t.lower()
|
||||||
|
if "taub" in t_lower:
|
||||||
return True
|
return True
|
||||||
if t == "Dea" or t.lower() in ("hörend", "hoerend"):
|
if "hörend" in t_lower or "hoerend" in t_lower:
|
||||||
|
return False
|
||||||
|
if "dea/dea" in t_lower or "dea/*" in t_lower:
|
||||||
|
return True
|
||||||
|
if t == "dea":
|
||||||
|
return True
|
||||||
|
if t == "Dea":
|
||||||
return False
|
return False
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
1790
tools/import/merge_and_resolve.py
Normal file
1790
tools/import/merge_and_resolve.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,15 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
|
|
||||||
## Überblick
|
## Überblick
|
||||||
|
|
||||||
- Rohe Tier-Einträge aus den Stammbäumen: **2451**
|
- Rohe Tier-Einträge aus den Stammbäumen: **2449**
|
||||||
- Nach Zusammenführung (eindeutige Tiere): **1007**
|
- Nach Zusammenführung (eindeutige Tiere): **1006**
|
||||||
- davon mit Geburtsdatum: 682
|
- davon mit Geburtsdatum: 681
|
||||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 461
|
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
|
||||||
- Konflikte zur Klärung: **7**
|
- Konflikte zur Klärung: **2**
|
||||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
|
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
|
||||||
- Fotos zugeordnet: **417**
|
- Fotos zugeordnet: **416**
|
||||||
- Würfe aus der Wurfchronik: **752**
|
- Würfe aus der Wurfchronik: **752**
|
||||||
- Tiere mit Wurf verknüpft: **274** (davon über Geburtsdatum **und** Eltern: 175, nur über Geburtsdatum: 99; mehrdeutig: 14)
|
- Tiere mit Wurf verknüpft: **271** (davon über Geburtsdatum **und** Eltern: 166, nur über Geburtsdatum: 105; mehrdeutig: 16)
|
||||||
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
||||||
|
|
||||||
## Zusammenführungs-Schlüssel
|
## Zusammenführungs-Schlüssel
|
||||||
@@ -34,12 +34,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|
|
||||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Joghurt von Privat | 06.09.2013 | aa CC D- ee GG PP spsp // aa Cc[-] D- ee UwUw PP spsp | Privat | 06.03.2017 | Stammbaum von Akio Kids, Stammbaum von Jin, Stammbaum von Kentucky |
|
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
|
||||||
| Ken'ichi | 01.03.2015 | AA CC DD Ee GG PP spsp | Agouti // DD-Tumor | 02.11.2018 | Stammbaum von Chesnut, Stammbaum von Emi, Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi |
|
|
||||||
| DD-Tumor bei Geschwister | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 | Stammbaum von Chesnut, Stammbaum von Emi |
|
|
||||||
| Kleiner Warnowrenner Elieus gen. Eragon | 18.05.2016 | aa CC D- Ee Gg pp Spsp [DP] // aa c[chm]c[chm] D- Ee Gg pp Spsp | — | 09.10.2019 | Stammbaum von Chesnut, Stammbaum von Jeremy, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Quied Soldier of Black Forest |
|
|
||||||
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | Polarfuchs | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
|
|
||||||
| Harumi | 21.02.2015 | aa Cc[chm] DD EE GG PP Spsp | — | 03.06.2017 // 11.01.2018 | Stammbaum von Danako, Stammbaum von Kalea, Stammbaum von Yurikas und Pintos Sohn |
|
|
||||||
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
|
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
|
||||||
|
|
||||||
## Mehrdeutige / unvollständige Einträge
|
## Mehrdeutige / unvollständige Einträge
|
||||||
@@ -160,7 +155,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/+` | 7 | ? |
|
| `/+` | 7 | ? |
|
||||||
| `[meliert]` | 4 | ? |
|
| `[meliert]` | 4 | ? |
|
||||||
| `[Dea/dea]` | 4 | ? |
|
|
||||||
| `(Kragen)` | 3 | ? |
|
| `(Kragen)` | 3 | ? |
|
||||||
| `/` | 3 | ? |
|
| `/` | 3 | ? |
|
||||||
| `-g` | 2 | ? |
|
| `-g` | 2 | ? |
|
||||||
@@ -168,7 +162,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
| `Tumor` | 2 | ? |
|
| `Tumor` | 2 | ? |
|
||||||
| `!` | 2 | ? |
|
| `!` | 2 | ? |
|
||||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||||
| `[dea/*]` | 2 | ? |
|
|
||||||
| `(KW` | 2 | ? |
|
| `(KW` | 2 | ? |
|
||||||
| `Cc[]` | 1 | ? |
|
| `Cc[]` | 1 | ? |
|
||||||
| `[WFNZ-Maroon]` | 1 | ? |
|
| `[WFNZ-Maroon]` | 1 | ? |
|
||||||
@@ -176,13 +169,15 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
| `[meliert]-[DP]` | 1 | ? |
|
| `[meliert]-[DP]` | 1 | ? |
|
||||||
| `!Knickschwanz-Gen!` | 1 | ? |
|
| `!Knickschwanz-Gen!` | 1 | ? |
|
||||||
| `-09.09.2018` | 1 | ? |
|
| `-09.09.2018` | 1 | ? |
|
||||||
| `[dea/dea]` | 1 | ? |
|
|
||||||
| `[DP?]` | 1 | ? |
|
| `[DP?]` | 1 | ? |
|
||||||
| `/+Dezember'2014` | 1 | ? |
|
| `/+Dezember'2014` | 1 | ? |
|
||||||
| `(Ansatz)` | 1 | ? |
|
| `(Ansatz)` | 1 | ? |
|
||||||
| `[WFNZ][dea/*]` | 1 | ? |
|
|
||||||
| `-psp` | 1 | ? |
|
| `-psp` | 1 | ? |
|
||||||
| `G(G)` | 1 | ? |
|
| `G(G)` | 1 | ? |
|
||||||
|
| `!Niereninsuffizienz!` | 1 | ? |
|
||||||
|
| `+09.07.2017` | 1 | ? |
|
||||||
|
| `+2018` | 1 | ? |
|
||||||
|
| `AAA` | 1 | ? |
|
||||||
|
|
||||||
## Wurfchronik — Datenqualitäts-Hinweise
|
## Wurfchronik — Datenqualitäts-Hinweise
|
||||||
|
|
||||||
|
|||||||
@@ -71,15 +71,38 @@ def main():
|
|||||||
|
|
||||||
# Load color variety seeds
|
# Load color variety seeds
|
||||||
print("Loading color variety seeds...")
|
print("Loading color variety seeds...")
|
||||||
|
variety_map = {}
|
||||||
|
|
||||||
|
# Load from C# ApplicationContext.cs catalog for stable database GUIDs (index + 1)
|
||||||
|
app_context_path = os.path.abspath(os.path.join(HERE, "..", "..", "GerbilManagerWebAPI", "ApplicationContext.cs"))
|
||||||
|
cs_name_to_id = {}
|
||||||
|
if os.path.exists(app_context_path):
|
||||||
|
with open(app_context_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
catalog_match = re.search(r'catalog\s*=\s*\{(.*?)\};', content, re.DOTALL)
|
||||||
|
if catalog_match:
|
||||||
|
block = catalog_match.group(1)
|
||||||
|
entries = re.findall(r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', block)
|
||||||
|
for idx, (name, genotype, sort_order) in enumerate(entries):
|
||||||
|
variety_id = f"00000000-0000-0000-0000-{idx + 1:012d}"
|
||||||
|
cs_name_to_id[name.strip().lower()] = variety_id
|
||||||
|
variety_map[name.strip().lower()] = variety_id
|
||||||
|
else:
|
||||||
|
print(f"Warning: ApplicationContext.cs not found at {app_context_path}")
|
||||||
|
|
||||||
with open(SEEDS_PATH, "r", encoding="utf-8") as f:
|
with open(SEEDS_PATH, "r", encoding="utf-8") as f:
|
||||||
seeds = json.load(f)
|
seeds = json.load(f)
|
||||||
|
|
||||||
# Map variety name -> Guid
|
|
||||||
variety_map = {}
|
|
||||||
for v in seeds:
|
for v in seeds:
|
||||||
# UUID is based on SortOrder + 1
|
name_lower = v["name"].strip().lower()
|
||||||
|
variety_id = cs_name_to_id.get(name_lower)
|
||||||
|
if not variety_id:
|
||||||
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
|
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
|
||||||
variety_map[v["name"].strip().lower()] = variety_id
|
variety_map[name_lower] = variety_id
|
||||||
|
|
||||||
|
# Map English name if present
|
||||||
|
if "english" in v and v["english"]:
|
||||||
|
variety_map[v["english"].strip().lower()] = variety_id
|
||||||
|
|
||||||
# 1. Establish stable Guid maps
|
# 1. Establish stable Guid maps
|
||||||
animal_guid_map = {a["id"]: generate_guid(f"animal-{a['id']}") for a in animals}
|
animal_guid_map = {a["id"]: generate_guid(f"animal-{a['id']}") for a in animals}
|
||||||
@@ -297,12 +320,12 @@ def main():
|
|||||||
elif not is_resident:
|
elif not is_resident:
|
||||||
status = "GivenAway"
|
status = "GivenAway"
|
||||||
else:
|
else:
|
||||||
# Age presumed deceased (>7 years)
|
# Age presumed deceased (>6 years)
|
||||||
if dob:
|
if dob:
|
||||||
dt_dob = datetime.strptime(dob, "%Y-%m-%d")
|
dt_dob = datetime.strptime(dob, "%Y-%m-%d")
|
||||||
dt_now = datetime.now()
|
dt_now = datetime.now()
|
||||||
age_years = (dt_now - dt_dob).days / 365.25
|
age_years = (dt_now - dt_dob).days / 365.25
|
||||||
if age_years >= 7.0:
|
if age_years >= 6.0:
|
||||||
status = "Deceased"
|
status = "Deceased"
|
||||||
else:
|
else:
|
||||||
status = "Breeding" # default to breeding for resident stock
|
status = "Breeding" # default to breeding for resident stock
|
||||||
@@ -344,13 +367,28 @@ def main():
|
|||||||
# Attach Photos
|
# Attach Photos
|
||||||
for idx, photo_rel in enumerate(a.get("photos", [])):
|
for idx, photo_rel in enumerate(a.get("photos", [])):
|
||||||
photo_guid = generate_guid(f"photo-{photo_rel}")
|
photo_guid = generate_guid(f"photo-{photo_rel}")
|
||||||
|
ext = os.path.splitext(photo_rel)[1] or ".jpeg"
|
||||||
|
fn_guid = photo_guid.replace("-", "")
|
||||||
resolved_photos.append({
|
resolved_photos.append({
|
||||||
"Id": photo_guid,
|
"Id": photo_guid,
|
||||||
"GerbilId": a_guid,
|
"GerbilId": a_guid,
|
||||||
"FileName": os.path.basename(photo_rel),
|
"FileName": f"{fn_guid}{ext}",
|
||||||
"SortOrder": idx
|
"SortOrder": idx,
|
||||||
|
"_source_path": photo_rel
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Set IsBreeder and IsReceiver flags on contacts
|
||||||
|
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
|
||||||
|
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
|
||||||
|
for c in resolved_contacts:
|
||||||
|
c_id = c["Id"]
|
||||||
|
is_breeder = c_id in breeder_ids
|
||||||
|
is_receiver = c_id in receiver_ids
|
||||||
|
if not is_breeder and not is_receiver:
|
||||||
|
is_receiver = True
|
||||||
|
c["IsBreeder"] = is_breeder
|
||||||
|
c["IsReceiver"] = is_receiver
|
||||||
|
|
||||||
# Save resolved data
|
# Save resolved data
|
||||||
resolved_data = {
|
resolved_data = {
|
||||||
"Contacts": resolved_contacts,
|
"Contacts": resolved_contacts,
|
||||||
|
|||||||
65
tools/import/run_app_and_import.ps1
Normal file
65
tools/import/run_app_and_import.ps1
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# run_app_and_import.ps1
|
||||||
|
# This script starts the .NET Aspire AppHost and automatically runs the database import once the WebAPI is ready.
|
||||||
|
|
||||||
|
Write-Host "Starting GerbilManager AppHost (this will boot PostgreSQL and the WebAPI)..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Start AppHost in the background
|
||||||
|
$appProcess = Start-Process dotnet -ArgumentList "run --project GerbilManager.AppHost" -PassThru -NoNewWindow
|
||||||
|
|
||||||
|
# Function to cleanup process on exit
|
||||||
|
function Cleanup {
|
||||||
|
Write-Host "`nStopping AppHost..." -ForegroundColor Yellow
|
||||||
|
if ($appProcess -and -not $appProcess.HasExited) {
|
||||||
|
Stop-Process -Id $appProcess.Id -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup }
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Poll the import endpoint until it becomes responsive (max 60 seconds)
|
||||||
|
$url = "http://localhost:5179/import/ingest-resolved"
|
||||||
|
$success = $false
|
||||||
|
Write-Host "Waiting for WebAPI to become responsive at $url..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt 30; $i++) {
|
||||||
|
try {
|
||||||
|
# Send a test request or check port
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:5179/openapi/v1.json" -Method Get -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
$success = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# WebAPI not ready yet
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $success) {
|
||||||
|
Write-Host "Error: WebAPI did not become ready within 60 seconds." -ForegroundColor Red
|
||||||
|
Cleanup
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "WebAPI is up! Triggering the database import..." -ForegroundColor Green
|
||||||
|
|
||||||
|
# Run the import POST request
|
||||||
|
try {
|
||||||
|
$importResponse = Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json"
|
||||||
|
Write-Host "Import Result: $importResponse" -ForegroundColor Green
|
||||||
|
Write-Host "`nThe application is now fully running and imported!" -ForegroundColor Cyan
|
||||||
|
Write-Host "Press Ctrl+C in this terminal to stop the application." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Keep script running to keep the app process alive
|
||||||
|
while ($true) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Error during database import: $_" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Cleanup
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user