Compare commits
4 Commits
bddbaf59d3
...
4cb39cb180
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb39cb180 | |||
| b53b8cf2ff | |||
| e460cd5905 | |||
| b83e96f552 |
@@ -38,9 +38,9 @@ public class GerbilStatusTests
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
|
||||
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
|
||||
[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 status = GerbilStatusService.Derive(
|
||||
@@ -48,14 +48,14 @@ public class GerbilStatusTests
|
||||
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]
|
||||
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(
|
||||
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)
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
@@ -70,11 +70,11 @@ public class GerbilStatusTests
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
|
||||
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
|
||||
[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(
|
||||
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
|
||||
@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
@@ -133,6 +134,10 @@ public class ApplicationContext : DbContext
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.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.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
|
||||
@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
|
||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||
/// 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(
|
||||
string Name,
|
||||
string Geschlecht,
|
||||
DateOnly? Geburtsdatum = null,
|
||||
string? Farbschlag = null);
|
||||
string? Farbschlag = null,
|
||||
byte[]? Photo = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
@@ -45,7 +45,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
|
||||
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);
|
||||
|
||||
@@ -89,7 +89,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
@@ -97,7 +97,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
|
||||
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);
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<Guid> GerbilIds,
|
||||
decimal Price,
|
||||
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>
|
||||
public record BreederProfileDto(
|
||||
@@ -29,5 +31,6 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City);
|
||||
string City,
|
||||
string NameSuffix);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver };
|
||||
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);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
@@ -35,7 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver);
|
||||
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 =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
@@ -50,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||
var photoChoice = input.AnimalPhotos ?? [];
|
||||
if (gerbilIds.Count == 0)
|
||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||
if (input.Price < 0)
|
||||
@@ -100,7 +102,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
ContractDate = contractDate,
|
||||
FileName = fileName,
|
||||
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);
|
||||
|
||||
@@ -144,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
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
|
||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
@@ -166,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
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()
|
||||
{
|
||||
ZuchtName = s.ZuchtName,
|
||||
|
||||
@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.NoContent();
|
||||
}).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}",
|
||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||
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.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
s.NameSuffix = input.NameSuffix ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
|
||||
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="Gridify" 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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
.Select(l => new { l.Id, l.Date })
|
||||
.ToListAsync();
|
||||
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());
|
||||
|
||||
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||
|
||||
@@ -83,7 +83,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
var existingLitterData = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||
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
|
||||
.Where(x => x.ExternalRef != null)
|
||||
.Select(x => x.ExternalRef!).ToHashSet();
|
||||
@@ -307,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
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);
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
@@ -329,8 +329,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||
Date = sl.Date ?? default,
|
||||
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
|
||||
Date = sl.Date,
|
||||
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
@@ -61,6 +62,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||
_db.SaleContracts.RemoveRange(_db.SaleContracts);
|
||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||
_db.Litters.RemoveRange(_db.Litters);
|
||||
_db.Contacts.RemoveRange(_db.Contacts);
|
||||
@@ -79,6 +81,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||
await _db.SaleContracts.ExecuteDeleteAsync();
|
||||
await _db.Gerbils.ExecuteDeleteAsync();
|
||||
await _db.Litters.ExecuteDeleteAsync();
|
||||
await _db.Contacts.ExecuteDeleteAsync();
|
||||
@@ -137,7 +140,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
var gerbilsToInsert = new List<Gerbil>();
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
gerbilsToInsert.Add(new Gerbil
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
@@ -163,7 +166,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
CharacterNote = g.CharacterNote,
|
||||
IsDeaf = g.IsDeaf,
|
||||
IsResident = g.IsResident
|
||||
});
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
gerbilsToInsert.Add(importedGerbil);
|
||||
}
|
||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
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()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("NameSuffix")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
Email = "",
|
||||
Homepage = "",
|
||||
Name = "",
|
||||
NameSuffix = "",
|
||||
Phone = "",
|
||||
ZuchtName = ""
|
||||
});
|
||||
@@ -727,6 +732,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("NameSuffix")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -756,6 +764,35 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -933,7 +970,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
b.Property<DateOnly?>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int?>("DeathsWithin8Weeks")
|
||||
@@ -1234,6 +1271,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("PhotoId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SaleContractId", "GerbilId");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
@@ -1302,6 +1342,15 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||
|
||||
@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
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>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
|
||||
@@ -18,5 +18,11 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace GerbilManagerWebAPI.Models
|
||||
[Key]
|
||||
public Guid Id { 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>
|
||||
public int? TotalBorn { get; set; }
|
||||
|
||||
@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
|
||||
public Guid SaleContractId { get; set; }
|
||||
public Guid GerbilId { 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>
|
||||
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.
|
||||
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
||||
|
||||
@@ -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.getByText(de.character.sectionTitle)).toBeVisible()
|
||||
|
||||
// DESIGN (Rennmausakte): Charakter sind anklickbare Chips (Buttons) statt Checkboxen.
|
||||
// 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 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.
|
||||
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')
|
||||
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||
|
||||
@@ -139,6 +139,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
name: 'Frau Erika Muster',
|
||||
address: 'Musterweg 1, 12345 Musterstadt',
|
||||
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
||||
nameSuffix: '',
|
||||
})
|
||||
}
|
||||
if (method === 'PUT') return json(route, 204)
|
||||
@@ -253,6 +254,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 405)
|
||||
}
|
||||
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$/)
|
||||
if (m) {
|
||||
const isKruemel = m[1] === 'kruemel'
|
||||
|
||||
@@ -63,21 +63,12 @@ test('Gehege-Feld Sichtbarkeit bei verstorbenen/abgegebenen Tieren', async ({ pa
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
|
||||
await expect(page.locator('dd', { hasText: 'Großbecken' })).toBeVisible()
|
||||
|
||||
// 2. Willi ist Deceased (Verstorben) -> Gehege ausgeblendet
|
||||
await page.goto('/rennmaeuse/willi')
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
|
||||
|
||||
// 3. Pauli ist GivenAway (Abgegeben), Abnehmer ist Familie Huber (isReceiver=true) -> Gehege eingeblendet
|
||||
await page.goto('/rennmaeuse/pauli')
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
|
||||
|
||||
// 4. Pauli Breeder ist GivenAway, Abnehmer ist Zoohandlung Meier (isBreeder=true) -> Gehege eingeblendet
|
||||
await page.goto('/rennmaeuse/pauli-breeder')
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
|
||||
|
||||
// 5. Pauli Neither ist GivenAway, Abnehmer ist Weder Noch (weder Züchter noch Abnehmer) -> Gehege ausgeblendet
|
||||
await page.goto('/rennmaeuse/pauli-neither')
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
|
||||
// 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 }) => {
|
||||
|
||||
@@ -178,8 +178,9 @@ test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)'
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Badge sichtbar
|
||||
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
|
||||
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
|
||||
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateContact {
|
||||
notes?: string | null
|
||||
isBreeder?: boolean
|
||||
isReceiver?: boolean
|
||||
nameSuffix?: string | null
|
||||
}
|
||||
|
||||
/** Payload for PUT /contacts/{id}. */
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface CreateSaleContract {
|
||||
price: number
|
||||
handoverDate: DateOnlyString
|
||||
contractDate?: DateOnlyString | null
|
||||
/** Optionales Vertragsfoto je Tier: GerbilId → GerbilPhoto-Id. */
|
||||
animalPhotos?: Record<string, string>
|
||||
}
|
||||
|
||||
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 {
|
||||
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 }
|
||||
@@ -10,6 +10,8 @@ export interface BreederProfile {
|
||||
homepage: string
|
||||
/** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */
|
||||
city: string
|
||||
/** Namens-Anhängsel („Nachname") der eigenen Zucht, z. B. „von den kleinen Chaoten“. */
|
||||
nameSuffix: string
|
||||
}
|
||||
|
||||
export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
||||
@@ -20,6 +22,7 @@ export const EMPTY_BREEDER_PROFILE: BreederProfile = {
|
||||
email: '',
|
||||
homepage: '',
|
||||
city: '',
|
||||
nameSuffix: '',
|
||||
}
|
||||
|
||||
/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface Contact {
|
||||
notes: string | null
|
||||
isBreeder: boolean
|
||||
isReceiver: boolean
|
||||
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
||||
nameSuffix: string | null
|
||||
}
|
||||
|
||||
export interface Litter {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import GerbilIcon from './GerbilIcon'
|
||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||
import './appShell.css'
|
||||
|
||||
interface NavItem {
|
||||
@@ -83,7 +84,9 @@ export default function AppShell() {
|
||||
</nav>
|
||||
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
<BreederSuffixProvider>
|
||||
<Outlet />
|
||||
</BreederSuffixProvider>
|
||||
</main>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
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.
|
||||
* Selbstständig ladend; rendert nichts, solange es keine Fotos gibt
|
||||
* (oder die Foto-Endpunkte noch nicht live sind).
|
||||
* FEAT-6: Profilfoto — erstes Foto nach sortOrder. Selbstständig ladend.
|
||||
* Ohne Foto rendert es `fallback` (oder nichts).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
||||
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 profile = profilePhoto(photos.data ?? [])
|
||||
if (!profile) return null
|
||||
return (
|
||||
<img
|
||||
className="profile-photo"
|
||||
src={photoSrc(profile)}
|
||||
alt={profile.caption ?? profile.fileName}
|
||||
/>
|
||||
)
|
||||
if (!profile) return <>{fallback}</>
|
||||
return <img className={className} src={photoSrc(profile)} alt={profile.caption ?? profile.fileName} />
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatDate, genderLabel } from '../format/labels'
|
||||
import { traitLabels } from '../format/traits'
|
||||
import FarbschlagImage from './FarbschlagImage'
|
||||
import Charakterbogen from './Charakterbogen'
|
||||
import { useGerbilName } from './breederSuffix'
|
||||
|
||||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||||
|
||||
@@ -32,6 +33,7 @@ function groupGender(animals: Gerbil[]): string {
|
||||
|
||||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||||
const t = de.pages.abgabe
|
||||
const gerbilName = useGerbilName()
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<SaleStatus>('free')
|
||||
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 className="group-composer__animal-head">
|
||||
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
||||
<strong>{a.name}</strong>
|
||||
<strong>{gerbilName(a) || a.name}</strong>
|
||||
<span className="muted">
|
||||
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
||||
{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)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -348,13 +348,65 @@ textarea {
|
||||
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 {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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) {
|
||||
.gerbil-card {
|
||||
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { installUnhandledRejectionGuard } from './dev/unhandledRejectionGuard'
|
||||
|
||||
installUnhandledRejectionGuard()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { Gerbil } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||
import GroupComposer from '../components/GroupComposer'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
import './abgabe.css'
|
||||
|
||||
const UNGROUPED = 'ungrouped'
|
||||
|
||||
export default function AbgabePage() {
|
||||
const t = de.pages.abgabe
|
||||
const gerbilName = useGerbilName()
|
||||
|
||||
const forSale = useApi(
|
||||
() =>
|
||||
@@ -82,7 +84,7 @@ export default function AbgabePage() {
|
||||
<ul className="abgabe-roster__list">
|
||||
{animals.map((a) => (
|
||||
<li key={a.id}>
|
||||
<span>{a.name}</span>
|
||||
<span>{gerbilName(a) || a.name}</span>
|
||||
<select
|
||||
value={assignment(a)}
|
||||
onChange={(e) => moveTo(a.id, e.target.value)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listGerbils } from '../api/gerbils'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { condition } from '../api/gridify'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
|
||||
|
||||
export default function BeckenDetailPage() {
|
||||
const t = de.pages.becken
|
||||
@@ -109,6 +110,9 @@ export default function BeckenDetailPage() {
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<h3>{t.photosTitle}</h3>
|
||||
<EnclosurePhotosSection enclosureId={e.id} />
|
||||
|
||||
<h3>{t.occupancy.title}</h3>
|
||||
{occupants.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{occupants.error && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -18,6 +19,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
export default function BeckenFormPage() {
|
||||
const t = de.pages.becken
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -45,8 +47,13 @@ export default function BeckenFormPage() {
|
||||
}
|
||||
setErrors({})
|
||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||
if (result.ok) navigate(`/gehege/${result.value.id}`)
|
||||
// On failure mutation.error drives the inline alert; run() never throws.
|
||||
if (result.ok) {
|
||||
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>
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
type BreederProfile,
|
||||
} from '../api/settings'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
// EXPORT-1 (Oscar): Datenexport-Karte
|
||||
import DatenexportCard from '../components/DatenexportCard'
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const t = de.pages.einstellungen
|
||||
const tz = t.zuchtprofil
|
||||
const toast = useToast()
|
||||
|
||||
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
@@ -40,7 +42,12 @@ export default function EinstellungenPage() {
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
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>
|
||||
@@ -81,6 +88,7 @@ export default function EinstellungenPage() {
|
||||
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
{field('zuchtName', tz.fields.zuchtName)}
|
||||
{field('nameSuffix', tz.fields.nameSuffix, tz.fields.nameSuffixHint)}
|
||||
{field('name', tz.fields.name, tz.fields.nameHint)}
|
||||
{field('address', tz.fields.address, tz.fields.addressHint)}
|
||||
{field('city', tz.fields.city)}
|
||||
|
||||
@@ -6,17 +6,30 @@ import { listLitters as listLittersPaged } from '../api/litters'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||
import type { Gender, GerbilStatus } from '../api/types'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import Charakterbogen from '../components/Charakterbogen'
|
||||
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
|
||||
import GerbilHealthTab from '../components/GerbilHealthTab'
|
||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
||||
import GerbilWeightTab from '../components/GerbilWeightTab'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
import { useToast } from '../components/toast'
|
||||
import './rennmausakte.css'
|
||||
|
||||
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. */
|
||||
function describeGenotype(genotype: string | null): { display: string; farbschlag: string } | null {
|
||||
if (!genotype || !genotype.trim()) return null
|
||||
@@ -24,22 +37,24 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
|
||||
const g = fromDisplayString(genotype)
|
||||
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
|
||||
} catch {
|
||||
// Unparseable: show the safely-rendered raw value, no derived Farbschlag.
|
||||
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 (
|
||||
<div className="def-row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{value ?? '—'}</dd>
|
||||
<div className="ak-kv">
|
||||
<dt className="ak-kv-label">{label}</dt>
|
||||
<dd className="ak-kv-value">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GerbilDetailPage() {
|
||||
const t = de.pages.gerbils
|
||||
const gerbilName = useGerbilName()
|
||||
const toast = useToast()
|
||||
const { id = '' } = useParams()
|
||||
const [tab, setTab] = useState<DetailTab>('photos')
|
||||
|
||||
@@ -49,7 +64,9 @@ export default function GerbilDetailPage() {
|
||||
const [charTraits, setCharTraits] = useState<string[]>([])
|
||||
const [charNote, setCharNote] = useState('')
|
||||
const [charInit, setCharInit] = useState<string | null>(null)
|
||||
const [isCharacterExpanded, setIsCharacterExpanded] = useState(true)
|
||||
// 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(() =>
|
||||
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
|
||||
)
|
||||
@@ -79,11 +96,26 @@ export default function GerbilDetailPage() {
|
||||
[litters.data],
|
||||
)
|
||||
|
||||
const receiverContact = useMemo(() => {
|
||||
const receiverId = gerbil.data?.receiverContactId
|
||||
if (!receiverId || !contacts.data) return null
|
||||
return contacts.data.find((c) => c.id === receiverId) ?? null
|
||||
}, [gerbil.data?.receiverContactId, contacts.data])
|
||||
// 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.error || !gerbil.data) {
|
||||
@@ -98,244 +130,329 @@ export default function GerbilDetailPage() {
|
||||
}
|
||||
|
||||
const g = gerbil.data
|
||||
const showEnclosure = g.status !== 'Deceased' && (
|
||||
g.status !== 'GivenAway' ||
|
||||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
|
||||
)
|
||||
// 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 lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||
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) {
|
||||
setCharInit(g.id)
|
||||
setCharTraits(g.characterTraits ?? [])
|
||||
setCharNote(g.characterNote ?? '')
|
||||
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
|
||||
setCharExpanded(g.status === 'Breeding' || g.status === 'Pet')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div className="profile-header-summary">
|
||||
<GerbilProfilePhoto gerbilId={g.id} />
|
||||
<div className="profile-header-info">
|
||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
||||
<div className="profile-header-badges">
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
<section className="ak">
|
||||
<div className="ak-hero">
|
||||
<GerbilProfilePhoto
|
||||
gerbilId={g.id}
|
||||
className="ak-herophoto"
|
||||
fallback={
|
||||
<div className="ak-herophoto ak-herophoto--empty" aria-hidden="true">
|
||||
🐭
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="ak-herobar">
|
||||
<Link to="/rennmaeuse" className="ak-back" aria-label={t.detail.back}>
|
||||
‹
|
||||
</Link>
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
||||
<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 && (
|
||||
<span className="ak-badge-extern" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</Link>
|
||||
<Link to={`/genetik?parent=${g.id}`} className="btn">
|
||||
<Link to={`/genetik?parent=${g.id}`} className="ak-btn">
|
||||
{t.detail.testMating}
|
||||
</Link>
|
||||
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="ak-btn">
|
||||
{de.pages.stammbaum.openButton}
|
||||
</Link>
|
||||
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="ak-btn"
|
||||
disabled={forSale.pending}
|
||||
onClick={async () => {
|
||||
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}
|
||||
</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' && (
|
||||
<Link to={`/vertraege/neu?tiere=${g.id}`} className="btn">
|
||||
<Link to={`/vertraege/neu?tiere=${g.id}`} className="ak-btn">
|
||||
{de.pages.vertraege.wizard.title}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/rennmaeuse" className="btn">
|
||||
<Link to="/rennmaeuse" className="ak-btn">
|
||||
{t.detail.back}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h3>{t.detail.masterData}</h3>
|
||||
<dl className="def-list">
|
||||
<Row
|
||||
label={t.fields.gender}
|
||||
value={
|
||||
<>
|
||||
<section className="ak-card">
|
||||
<h2 className="ak-h2">{t.detail.masterData}</h2>
|
||||
<dl className="ak-kvlist">
|
||||
<Kv label={t.fields.gender}>
|
||||
{genderLabel(g.gender)}
|
||||
{g.isCastrated && <span style={{ marginLeft: '0.5rem', opacity: 0.8 }}>(Kastrat)</span>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Row label={t.fields.status} value={statusLabel(g.status)} />
|
||||
<Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} />
|
||||
{g.status === 'Deceased' && (
|
||||
<>
|
||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
|
||||
</>
|
||||
)}
|
||||
{g.status === 'GivenAway' && (
|
||||
<Row label={t.fields.goHomeDate} value={formatDate(g.goHomeDate)} />
|
||||
)}
|
||||
<Row
|
||||
label={t.fields.colorVariety}
|
||||
value={
|
||||
storedColorName ? (
|
||||
<span className="farbschlag-value">
|
||||
<FarbschlagImage name={storedColorName} size={32} />
|
||||
{storedColorName}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)
|
||||
}
|
||||
/>
|
||||
{g.spottingType && (
|
||||
<Row
|
||||
label={t.fields.spottingType}
|
||||
value={
|
||||
de.pages.gerbils.spottingTypes[g.spottingType as keyof typeof de.pages.gerbils.spottingTypes] ||
|
||||
g.spottingType
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showEnclosure && (
|
||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
||||
)}
|
||||
<Row
|
||||
label={t.fields.litter}
|
||||
value={
|
||||
g.litterId && litterName.has(g.litterId)
|
||||
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||
: lookup(litterName, g.litterId)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={t.fields.origin}
|
||||
value={
|
||||
g.originContactId && contactName.has(g.originContactId)
|
||||
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
||||
: (g.originBreeder || null)
|
||||
}
|
||||
/>
|
||||
{g.status !== 'Deceased' && (
|
||||
<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={
|
||||
{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' && (
|
||||
<>
|
||||
<Kv label={t.fields.dateOfDeath}>{formatDate(g.dateOfDeath)}</Kv>
|
||||
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
|
||||
</>
|
||||
)}
|
||||
{g.status === 'GivenAway' && (
|
||||
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
|
||||
)}
|
||||
<Kv label={t.fields.colorVariety}>
|
||||
{storedColorName ? (
|
||||
<>
|
||||
<FarbschlagImage name={storedColorName} size={22} />
|
||||
{storedColorName}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Kv>
|
||||
{g.spottingType && (
|
||||
<Kv label={t.fields.spottingType}>
|
||||
{de.pages.gerbils.spottingTypes[
|
||||
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
|
||||
] || g.spottingType}
|
||||
</Kv>
|
||||
)}
|
||||
{showEnclosure && (
|
||||
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
|
||||
)}
|
||||
<Kv label={t.fields.litter}>
|
||||
{g.litterId && litterName.has(g.litterId) ? (
|
||||
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||
) : (
|
||||
lookup(litterName, g.litterId)
|
||||
)}
|
||||
</Kv>
|
||||
{ownLitter && (
|
||||
<>
|
||||
<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}
|
||||
{storedColorName &&
|
||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
||||
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||
<small className="mismatch-hint"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="muted">{t.detail.genotypeNotSet}</p>
|
||||
)}
|
||||
</Kv>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="ak-empty">{t.detail.genotypeNotSet}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details
|
||||
className="charakter-details"
|
||||
open={isCharacterExpanded}
|
||||
onToggle={(e) => setIsCharacterExpanded(e.currentTarget.open)}
|
||||
>
|
||||
<summary className="charakter-summary">
|
||||
<h3>{de.character.sectionTitle}</h3>
|
||||
<span className="charakter-chevron" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div className="charakter-body">
|
||||
<Charakterbogen
|
||||
traits={charTraits}
|
||||
note={charNote}
|
||||
onTraitsChange={setCharTraits}
|
||||
onNoteChange={setCharNote}
|
||||
<section className="ak-card">
|
||||
<button
|
||||
type="button"
|
||||
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="form-actions" style={{ marginTop: '1rem' }}>
|
||||
<div className="ak-saverow">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
className="ak-btn primary"
|
||||
disabled={saveCharacter.pending}
|
||||
onClick={async () => {
|
||||
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}
|
||||
</button>
|
||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ak-card">
|
||||
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="ak-empty">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id
|
||||
? t.detail.parentLittersRoleVater
|
||||
: t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ak-card">
|
||||
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
||||
<div className="ak-tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={tab === key ? 'ak-tab active' : 'ak-tab'}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{t.detail.tabs[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3>{t.detail.parentLittersTitle}</h3>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={tab === key ? 'tab tab--active' : 'tab'}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{t.detail.tabs[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tab-panel" role="tabpanel">
|
||||
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
||||
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
||||
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
||||
<div className="ak-tabpanel" role="tabpanel">
|
||||
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
||||
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
||||
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { getBreederProfile } from '../api/settings'
|
||||
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import ColorVarietyPicker from '../components/ColorVarietyPicker'
|
||||
import { useToast } from '../components/toast'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
@@ -120,6 +122,7 @@ function isGenotypeValid(genotype: string): boolean {
|
||||
export default function GerbilFormPage() {
|
||||
const t = de.pages.gerbils
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -133,6 +136,9 @@ export default function GerbilFormPage() {
|
||||
const enclosures = useApi(() => listEnclosures(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
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
|
||||
// during render" pattern — runs without an effect and re-renders immediately.
|
||||
@@ -203,7 +209,12 @@ export default function GerbilFormPage() {
|
||||
isCastrated: form.isCastrated,
|
||||
}
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -245,7 +256,11 @@ export default function GerbilFormPage() {
|
||||
<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>
|
||||
<option value="">
|
||||
{breederProfile.data?.zuchtName?.trim()
|
||||
? `${t.form.ownBreeding} — ${breederProfile.data.zuchtName.trim()}`
|
||||
: t.form.ownBreeding}
|
||||
</option>
|
||||
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -473,17 +488,20 @@ export default function GerbilFormPage() {
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.enclosure}</span>
|
||||
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
||||
<option value="">{t.form.none}</option>
|
||||
{(enclosures.data ?? []).map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege. */}
|
||||
{form.status !== 'GivenAway' && form.status !== 'Deceased' && (
|
||||
<label className="field">
|
||||
<span>{t.fields.enclosure}</span>
|
||||
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
|
||||
<option value="">{t.form.none}</option>
|
||||
{(enclosures.data ?? []).map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.litter}</span>
|
||||
|
||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createContact, getContact, updateContact, type CreateContact } from '../api/contacts'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -14,9 +15,19 @@ interface FormState {
|
||||
notes: string
|
||||
isBreeder: boolean
|
||||
isReceiver: boolean
|
||||
nameSuffix: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '', isBreeder: false, isReceiver: true }
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
notes: '',
|
||||
isBreeder: false,
|
||||
isReceiver: true,
|
||||
nameSuffix: '',
|
||||
}
|
||||
|
||||
/** "" -> null, sonst der Wert. */
|
||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
@@ -24,6 +35,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
export default function KontaktFormPage() {
|
||||
const t = de.pages.kontakte
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -44,6 +56,7 @@ export default function KontaktFormPage() {
|
||||
notes: existing.data.notes ?? '',
|
||||
isBreeder: existing.data.isBreeder,
|
||||
isReceiver: existing.data.isReceiver,
|
||||
nameSuffix: existing.data.nameSuffix ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,9 +82,15 @@ export default function KontaktFormPage() {
|
||||
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}`)
|
||||
// On failure mutation.error drives the inline alert; run() never throws.
|
||||
if (result.ok) {
|
||||
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>
|
||||
@@ -140,6 +159,18 @@ export default function KontaktFormPage() {
|
||||
/>
|
||||
</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">
|
||||
<span>{t.fields.notes}</span>
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
|
||||
@@ -1,56 +1,126 @@
|
||||
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
|
||||
import { useState } from 'react'
|
||||
/** FEAT-2: Kontakte-Liste — Quick-Chip-Filter (Rolle), Sticky-Header + Infinite Scroll. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { condition } from '../api/gridify'
|
||||
import { andFilter, condition } from '../api/gridify'
|
||||
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
|
||||
|
||||
export default function KontaktePage() {
|
||||
const t = de.pages.kontakte
|
||||
const [search, setSearch] = useState('')
|
||||
// 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 categories: FilterCategory[] = [
|
||||
{
|
||||
key: 'rolle',
|
||||
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([])
|
||||
|
||||
// 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 filter = search.trim()
|
||||
? condition({ field: 'name', op: 'contains', value: search.trim() })
|
||||
: undefined
|
||||
// Infinite scroll: one page at a time, accumulated. The trailing `id` makes the
|
||||
// ordering a total order so OFFSET pagination never repeats/omits a row.
|
||||
const list = useInfiniteList(
|
||||
(page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
`${filter ?? ''}`,
|
||||
(page) =>
|
||||
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
|
||||
filter,
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(list)
|
||||
const { items, total, hasMore, loading, loadingMore } = list
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
{total} {t.countLabel}
|
||||
</p>
|
||||
<section className="page gerbils-page">
|
||||
<div ref={topSentinelRef} className="gerbils-scrolltop" aria-hidden="true" />
|
||||
|
||||
<div className="gerbils-topbar">
|
||||
<header className={`page-head gerbils-head${condensed ? ' gerbils-head--condensed' : ''}`}>
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
{total} {t.countLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/kontakte/neu" className="btn btn--primary">
|
||||
+ {t.newButton}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="rmf-searchbar">
|
||||
<span className="rmf-search-ico" aria-hidden="true">
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="rmf-searchfield"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="rmf-search-clear"
|
||||
onClick={() => setSearch('')}
|
||||
aria-label={de.filterBar.searchClear}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/kontakte/neu" className="btn btn--primary">
|
||||
+ {t.newButton}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="filters">
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
<GerbilFilterBar categories={categories} selected={selected} onToggle={toggleRole} />
|
||||
</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">
|
||||
@@ -66,7 +136,7 @@ export default function KontaktePage() {
|
||||
{items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<li key={c.id} className="gerbil-row">
|
||||
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">
|
||||
{c.name}
|
||||
@@ -82,7 +152,7 @@ export default function KontaktePage() {
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
Züchter
|
||||
{t.filters.breeder}
|
||||
</span>
|
||||
)}
|
||||
{c.isReceiver && (
|
||||
@@ -97,7 +167,7 @@ export default function KontaktePage() {
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
Abnehmer
|
||||
{t.filters.receiver}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
@@ -81,6 +81,9 @@ export default function VertraegeListPage() {
|
||||
{t.fields.handoverDate} {formatDate(c.handoverDate)}
|
||||
</span>
|
||||
<span className="head-actions">
|
||||
<a className="btn btn--primary" href={contractPdfUrl(c)}>
|
||||
{t.downloadPdf}
|
||||
</a>
|
||||
<a className="btn" href={contractFileUrl(c)}>
|
||||
{t.download}
|
||||
</a>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag
|
||||
* 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 { de } from '../strings/de'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
@@ -16,6 +16,7 @@ import { createContact, listContactsPaged } from '../api/contacts'
|
||||
import { contractFileUrl, createContract, type SaleContract } from '../api/contracts'
|
||||
import { getBreederProfile, isBreederProfileComplete } from '../api/settings'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import './vertragWizard.css'
|
||||
@@ -110,6 +111,11 @@ export default function VertragWizardPage() {
|
||||
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 ── */
|
||||
const [priceText, setPriceText] = useState('')
|
||||
const [handoverDate, setHandoverDate] = useState(todayIso())
|
||||
@@ -127,6 +133,12 @@ export default function VertragWizardPage() {
|
||||
price: parsePrice(priceText)!,
|
||||
handoverDate,
|
||||
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() {
|
||||
@@ -339,6 +351,13 @@ export default function VertragWizardPage() {
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</label>
|
||||
{selectedIds.has(g.id) && (
|
||||
<ContractPhotoPicker
|
||||
gerbilId={g.id}
|
||||
chosen={animalPhotos[g.id]}
|
||||
onChange={(photoId) => setAnimalPhoto(g.id, photoId)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -450,3 +469,57 @@ export default function VertragWizardPage() {
|
||||
</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,14 +1,15 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
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 { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
||||
import { formatDate } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -53,14 +54,19 @@ export default function WuerfeListPage() {
|
||||
const [year, setYear] = useState('')
|
||||
const [sort, setSort] = useState<SortKey>('dateDesc')
|
||||
|
||||
// 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 nameById = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
|
||||
const gerbilById = useMemo(() => {
|
||||
const map = new Map<string, Gerbil>()
|
||||
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)
|
||||
return map
|
||||
}, [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).
|
||||
const filter = andFilter(
|
||||
@@ -90,7 +96,7 @@ export default function WuerfeListPage() {
|
||||
parentName(p.fatherId).toLowerCase().includes(term) ||
|
||||
parentName(p.motherId).toLowerCase().includes(term)
|
||||
)
|
||||
}, [pairs, search, nameById])
|
||||
}, [pairs, search, parentName])
|
||||
|
||||
const items = litters.items
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
|
||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||
import BreedingResultView from '../components/BreedingResultView'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import { useGerbilName } from '../components/breederSuffix'
|
||||
import './wuerfe.css'
|
||||
|
||||
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
|
||||
@@ -23,6 +24,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
receiver: string | null
|
||||
tJf: JuvenileFields
|
||||
}) {
|
||||
const gerbilName = useGerbilName()
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
@@ -46,7 +48,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</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() {
|
||||
const t = de.pages.litters
|
||||
const gerbilName = useGerbilName()
|
||||
const { id = '' } = useParams()
|
||||
|
||||
const litter = useApi(() => getLitter(id), [id])
|
||||
@@ -137,8 +140,8 @@ export default function WurfDetailPage() {
|
||||
|
||||
const l = litter.data
|
||||
const registered = juveniles.data?.totalCount ?? 0
|
||||
const parentLink = (id: string | null, g: { name: string } | null) =>
|
||||
id && g ? <Link to={`/rennmaeuse/${id}`}>{g.name}</Link> : t.detail.unknownParent
|
||||
const parentLink = (id: string | null, g: Gerbil | null) =>
|
||||
id && g ? <Link to={`/rennmaeuse/${id}`}>{gerbilName(g) || de.pages.gerbils.nameless}</Link> : t.detail.unknownParent
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLitter, getLitter, updateLitter } from '../api/litters'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import type { CreateLitter } from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import AnimalPicker from '../components/AnimalPicker'
|
||||
|
||||
interface FormState {
|
||||
@@ -47,6 +48,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
export default function WurfFormPage() {
|
||||
const t = de.pages.litters
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
@@ -126,6 +128,7 @@ export default function WurfFormPage() {
|
||||
setSubmitError(null)
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) {
|
||||
toast.success(de.common.saved)
|
||||
navigate(`/wuerfe/${result.value.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ export const de = {
|
||||
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
|
||||
showExternalShort: 'Externe Ahnen',
|
||||
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
|
||||
sort: {
|
||||
@@ -125,6 +128,8 @@ export const de = {
|
||||
createTitle: 'Neue Rennmaus anlegen',
|
||||
editTitle: 'Rennmaus bearbeiten',
|
||||
none: '— keine Angabe —',
|
||||
// Herkunft-Standardoption: eigene Zucht (leere Herkunft = selbst gezüchtet).
|
||||
ownBreeding: 'Eigene Zucht',
|
||||
genotypeHint:
|
||||
'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)
|
||||
@@ -360,6 +365,8 @@ export const de = {
|
||||
name: 'Name',
|
||||
notes: 'Notizen',
|
||||
},
|
||||
// Bilder-Sektion auf der Gehege-Detailseite.
|
||||
photosTitle: 'Bilder',
|
||||
occupancy: {
|
||||
title: 'Bewohner',
|
||||
empty: 'Dieses Gehege ist derzeit leer.',
|
||||
@@ -447,7 +454,8 @@ export const de = {
|
||||
newButton: 'Neuer Vertrag',
|
||||
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`),
|
||||
download: 'Herunterladen',
|
||||
download: 'Word',
|
||||
downloadPdf: 'PDF',
|
||||
delete: 'Löschen',
|
||||
confirmDelete:
|
||||
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
|
||||
@@ -479,6 +487,8 @@ export const de = {
|
||||
pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.',
|
||||
noAnimals: 'Keine abgebbaren Tiere gefunden.',
|
||||
animalsRequired: 'Bitte mindestens ein Tier auswählen.',
|
||||
photoLabel: 'Foto für den Vertrag',
|
||||
photoNone: 'Kein Foto',
|
||||
// Schritt 3
|
||||
priceLabel: 'Kaufpreis (€)',
|
||||
pricePlaceholder: 'z. B. 72,00',
|
||||
@@ -511,6 +521,9 @@ export const de = {
|
||||
'Noch unvollständig: Name, Adresse und Ort werden für den Vertrag benötigt.',
|
||||
fields: {
|
||||
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',
|
||||
nameHint: 'Mit Anrede, z. B. „Frau Erika Muster“.',
|
||||
address: 'Adresse',
|
||||
@@ -587,6 +600,12 @@ export const de = {
|
||||
empty: 'Keine Kontakte gefunden.',
|
||||
countLabel: 'Kontakte',
|
||||
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
|
||||
// (DATA-2-Schema: email/phone/address) — gebraucht für den Käufer-Block der Verträge.
|
||||
fields: {
|
||||
@@ -597,6 +616,9 @@ export const de = {
|
||||
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: {
|
||||
title: 'Verknüpfte Tiere',
|
||||
@@ -822,6 +844,10 @@ export const de = {
|
||||
delete: 'Löschen',
|
||||
confirmDelete: 'Wirklich löschen?',
|
||||
pageNav: 'Seitennavigation',
|
||||
// Toast-Rückmeldungen
|
||||
saved: 'Gespeichert',
|
||||
saveFailed: 'Speichern fehlgeschlagen',
|
||||
deleted: 'Gelöscht',
|
||||
},
|
||||
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
|
||||
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
||||
|
||||
@@ -211,6 +211,7 @@ def extract_stammbaum(path):
|
||||
if not compact and not is_block_dob:
|
||||
continue
|
||||
|
||||
name_row = r
|
||||
if compact:
|
||||
name = clean_name(compact.group(1))
|
||||
dob, death, geno = parse_detail(t)
|
||||
@@ -225,6 +226,7 @@ def extract_stammbaum(path):
|
||||
if (c, rr) in cells and not re.match(r"^\*?\s?\d", cells[(c, rr)]) \
|
||||
and not gt.looks_like_genotype(cells[(c, rr)]):
|
||||
name = clean_name(cells[(c, rr)])
|
||||
name_row = rr
|
||||
used.add((c, rr))
|
||||
break
|
||||
farbschlag = ""
|
||||
@@ -275,7 +277,7 @@ def extract_stammbaum(path):
|
||||
"sourceFiles": [fname],
|
||||
"_gen": gen_of(c, col_offset),
|
||||
"_col": c,
|
||||
"_row": r,
|
||||
"_row": name_row,
|
||||
"_file": fname,
|
||||
"_zucht": norm_zucht(zraw),
|
||||
})
|
||||
@@ -323,9 +325,10 @@ def _reconstruct_parents(animals):
|
||||
father = above[-1] if above else None
|
||||
mother = below[0] if below else None
|
||||
for parent, role in ((father, "father"), (mother, "mother")):
|
||||
if parent and parent["name"]:
|
||||
if parent:
|
||||
p_name = parent["name"] or "unbekannt"
|
||||
a["parentRefs"].append({
|
||||
"name": parent["name"],
|
||||
"name": p_name,
|
||||
"dob": parent["dob"],
|
||||
"roleGuess": role,
|
||||
"method": "chart-position",
|
||||
@@ -348,7 +351,7 @@ def _attach_photos(z, sheets, animals, fname):
|
||||
if not cands:
|
||||
# fall back to nearest animal by row across all gens
|
||||
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:
|
||||
continue
|
||||
ext = os.path.splitext(media)[1] or ".img"
|
||||
|
||||
@@ -231,6 +231,34 @@ def get_normalized_contact_name(name):
|
||||
|
||||
return name, True
|
||||
|
||||
def get_normalized_gerbil_name(name):
|
||||
if not name:
|
||||
return ""
|
||||
n = name.strip()
|
||||
norm_key = "".join(c for c in n.lower() if c.isalnum())
|
||||
|
||||
gerbil_norm_map = {
|
||||
"samgenshellyvdbuntenfellnasen": "Sammy gen. Shelly von den bunten Fellnasen",
|
||||
"sammygenshellyvdbuntenfellnasen": "Sammy gen. Shelly von den bunten Fellnasen",
|
||||
"schmidt": "Schmidti",
|
||||
"sheila": "Sheila of Ulmer Strolche",
|
||||
"shinichi": "Shinichi von PZ Mücke",
|
||||
"silenosgenadonis": "Silenos gen. Adonis von den Kleinen Chaoten",
|
||||
"silenosgenadonisvdkleinenchaoten": "Silenos gen. Adonis von den Kleinen Chaoten",
|
||||
"silver": "Silver von den kleinen Chaoten",
|
||||
"snoops": "Snoopsi",
|
||||
"sokrates": "Sokrates von den Kleinen Chaoten",
|
||||
"splash": "Slash",
|
||||
"teiko": "Teiko von den kleinen Chaoten",
|
||||
"trixy": "Trixxy von den Kleinen Chaoten",
|
||||
"unique": "Unique of Wild Dreams",
|
||||
}
|
||||
|
||||
if norm_key in gerbil_norm_map:
|
||||
return gerbil_norm_map[norm_key]
|
||||
|
||||
return n
|
||||
|
||||
def get_call_name(name):
|
||||
if not name:
|
||||
return ""
|
||||
@@ -485,15 +513,42 @@ def main():
|
||||
print("Loading color variety seeds...")
|
||||
variety_map = {}
|
||||
variety_genotypes = {}
|
||||
|
||||
# Load from C# ApplicationContext.cs catalog for stable database GUIDs (index + 1)
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
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
|
||||
variety_genotypes[variety_id] = genotype.strip()
|
||||
else:
|
||||
print(f"Warning: ApplicationContext.cs not found at {app_context_path}")
|
||||
|
||||
if os.path.exists(SEEDS_PATH):
|
||||
with open(SEEDS_PATH, "r", encoding="utf-8") as f:
|
||||
seeds = json.load(f)
|
||||
for v in seeds:
|
||||
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
|
||||
variety_map[v["name"].strip().lower()] = variety_id
|
||||
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_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
|
||||
variety_genotypes[variety_id] = v.get("canonicalGenotype")
|
||||
|
||||
if variety_id not in variety_genotypes:
|
||||
variety_genotypes[variety_id] = v.get("canonicalGenotype")
|
||||
else:
|
||||
print(f"Warning: Seeds path not found at {SEEDS_PATH}")
|
||||
|
||||
@@ -694,8 +749,8 @@ def main():
|
||||
for dl in docx_litters:
|
||||
l_name = dl["litterId"]
|
||||
dob_val = parse_date(dl["dob"])
|
||||
f_name = dl["fatherName"]
|
||||
m_name = dl["motherName"]
|
||||
f_name = get_normalized_gerbil_name(dl["fatherName"])
|
||||
m_name = get_normalized_gerbil_name(dl["motherName"])
|
||||
ws_code = dl["wsCode"]
|
||||
note_val = dl.get("note")
|
||||
|
||||
@@ -719,7 +774,7 @@ def main():
|
||||
raw_litters.append({
|
||||
"Id": l_scoped_id,
|
||||
"Name": l_name,
|
||||
"Date": dob_val or "0001-01-01",
|
||||
"Date": dob_val,
|
||||
"TotalBorn": total_born,
|
||||
"DeathsWithin8Weeks": deaths_8w,
|
||||
"FatherId": generate_guid(f"stammbaum-animal-{normalize_name(f_name)}"), # placeholder
|
||||
@@ -784,8 +839,8 @@ def main():
|
||||
# Pre-index Wurfchronik litters from markdown
|
||||
md_litters_idx = {}
|
||||
for rl in raw_litters:
|
||||
f_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName")
|
||||
m_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName")
|
||||
f_name = get_normalized_gerbil_name(rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName"))
|
||||
m_name = get_normalized_gerbil_name(rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName"))
|
||||
ldate = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth"))
|
||||
if f_name and m_name and ldate:
|
||||
key = (normalize_name(f_name), normalize_name(m_name), ldate)
|
||||
@@ -800,8 +855,8 @@ def main():
|
||||
|
||||
a["_mapped_litter_scoped_id"] = None
|
||||
if father_ref and mother_ref:
|
||||
f_name = father_ref.get("name")
|
||||
m_name = mother_ref.get("name")
|
||||
f_name = get_normalized_gerbil_name(father_ref.get("name"))
|
||||
m_name = get_normalized_gerbil_name(mother_ref.get("name"))
|
||||
dob_val = parse_date(a.get("dob"))
|
||||
|
||||
mapped_litter = None
|
||||
@@ -827,22 +882,36 @@ def main():
|
||||
m_dob = parse_date(mother_ref.get("dob"))
|
||||
|
||||
for p_cand in stammbaum_only_animals:
|
||||
cand_call_norm = normalize_name(get_call_name(p_cand["name"]))
|
||||
if cand_call_norm == normalize_name(f_name) or normalize_name(p_cand["name"]) == normalize_name(f_name):
|
||||
if not f_dob or parse_date(p_cand.get("dob")) == f_dob:
|
||||
p_gender = str(p_cand.get("gender") or "").lower().strip()
|
||||
if p_gender in ["w", "f", "female", "weiblich"]:
|
||||
continue
|
||||
p_dob = parse_date(p_cand.get("dob"))
|
||||
if dob_val and p_dob and p_dob >= dob_val:
|
||||
continue
|
||||
cand_call_norm = normalize_name(get_call_name(p_cand["name"])) or "unbekannt"
|
||||
cand_name_norm = normalize_name(p_cand["name"]) or "unbekannt"
|
||||
if cand_call_norm == normalize_name(f_name) or cand_name_norm == normalize_name(f_name):
|
||||
if not f_dob or p_dob == f_dob:
|
||||
f_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}")
|
||||
break
|
||||
for p_cand in stammbaum_only_animals:
|
||||
cand_call_norm = normalize_name(get_call_name(p_cand["name"]))
|
||||
if cand_call_norm == normalize_name(m_name) or normalize_name(p_cand["name"]) == normalize_name(m_name):
|
||||
if not m_dob or parse_date(p_cand.get("dob")) == m_dob:
|
||||
p_gender = str(p_cand.get("gender") or "").lower().strip()
|
||||
if p_gender in ["m", "male", "männlich"]:
|
||||
continue
|
||||
p_dob = parse_date(p_cand.get("dob"))
|
||||
if dob_val and p_dob and p_dob >= dob_val:
|
||||
continue
|
||||
cand_call_norm = normalize_name(get_call_name(p_cand["name"])) or "unbekannt"
|
||||
cand_name_norm = normalize_name(p_cand["name"]) or "unbekannt"
|
||||
if cand_call_norm == normalize_name(m_name) or cand_name_norm == normalize_name(m_name):
|
||||
if not m_dob or p_dob == m_dob:
|
||||
m_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}")
|
||||
break
|
||||
|
||||
raw_litters.append({
|
||||
"Id": l_scoped_id,
|
||||
"Name": f"Wurf von {f_name} + {m_name}",
|
||||
"Date": dob_val or "0001-01-01",
|
||||
"Date": dob_val,
|
||||
"TotalBorn": None,
|
||||
"DeathsWithin8Weeks": None,
|
||||
"FatherId": f_scoped_id or generate_guid(f"stammbaum-animal-{normalize_name(f_name)}"),
|
||||
@@ -924,8 +993,6 @@ def main():
|
||||
name_val = "Wurf"
|
||||
|
||||
dob_val = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth"))
|
||||
if not dob_val:
|
||||
dob_val = "0001-01-01"
|
||||
|
||||
new_guid = rl["_scoped_id"]
|
||||
if not new_guid:
|
||||
@@ -986,7 +1053,7 @@ def main():
|
||||
def get_litter_date(l_id):
|
||||
if l_id in litter_by_scoped_id:
|
||||
d = litter_by_scoped_id[l_id]["Date"]
|
||||
if d and d != "0001-01-01":
|
||||
if d:
|
||||
return d
|
||||
return None
|
||||
|
||||
@@ -994,7 +1061,7 @@ def main():
|
||||
all_processed_gerbils = []
|
||||
for rg in raw_gerbils:
|
||||
filename = rg.get("_filename")
|
||||
name_val = rg.get("Name") or rg.get("name") or rg.get("callName")
|
||||
name_val = get_normalized_gerbil_name(rg.get("Name") or rg.get("name") or rg.get("callName"))
|
||||
if not name_val:
|
||||
name_val = "Unbekannt"
|
||||
|
||||
@@ -1142,7 +1209,7 @@ def main():
|
||||
# Map and append stammbaum animals to all_processed_gerbils
|
||||
for a in stammbaum_only_animals:
|
||||
a_id = a["id"]
|
||||
name_val = a["name"]
|
||||
name_val = get_normalized_gerbil_name(a["name"])
|
||||
|
||||
gender_val = str(a.get("gender") or "").lower().strip()
|
||||
if gender_val in ["m", "male"]:
|
||||
@@ -1166,7 +1233,7 @@ def main():
|
||||
dt_dob = datetime.strptime(dob_val, "%Y-%m-%d")
|
||||
dt_now = datetime.now()
|
||||
age_years = (dt_now - dt_dob).days / 365.25
|
||||
if age_years >= 7.0:
|
||||
if age_years >= 6.0:
|
||||
status = "Deceased"
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1249,7 +1316,7 @@ def main():
|
||||
|
||||
# Map and append docx animals to all_processed_gerbils
|
||||
for idx, da in enumerate(docx_animals):
|
||||
name_val = da["name"]
|
||||
name_val = get_normalized_gerbil_name(da["name"])
|
||||
gender = da["gender"]
|
||||
|
||||
dob_val = parse_date(da.get("litterDob"))
|
||||
@@ -1335,7 +1402,7 @@ def main():
|
||||
|
||||
for l in resolved_litters:
|
||||
ld = l["Date"]
|
||||
if ld and ld != "0001-01-01":
|
||||
if ld:
|
||||
for pid in [l["FatherId"], l["MotherId"]]:
|
||||
if pid:
|
||||
parent_litter_dates.setdefault(pid, []).append(ld)
|
||||
@@ -1459,6 +1526,8 @@ def main():
|
||||
if ph not in merged_photos:
|
||||
merged_photos.append(ph)
|
||||
|
||||
if not best_g.get("_old_scoped_litter_id") and g.get("_old_scoped_litter_id"):
|
||||
best_g["_old_scoped_litter_id"] = g["_old_scoped_litter_id"]
|
||||
if not best_g["LitterId"] and g["LitterId"]:
|
||||
best_g["LitterId"] = g["LitterId"]
|
||||
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
|
||||
@@ -1499,6 +1568,22 @@ def main():
|
||||
if not any(kw in g["Notes"].lower() for kw in ["parent listed", "mutter von", "vater von", "dam of", "sire of"]):
|
||||
merged_notes.append(g["Notes"])
|
||||
|
||||
# Reconcile fields based on number of source files supporting them
|
||||
for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]:
|
||||
votes = {}
|
||||
for g in sub:
|
||||
val = g.get(field)
|
||||
if val and val != "unknown":
|
||||
# count source files
|
||||
sources_count = len(str(g.get("ImportSource") or "").split(","))
|
||||
votes[val] = votes.get(val, 0) + sources_count
|
||||
if votes:
|
||||
best_val = max(votes, key=votes.get)
|
||||
best_g[field] = best_val
|
||||
# Keep helper fields in sync if we changed DateOfBirth
|
||||
if field == "DateOfBirth":
|
||||
best_g["_birth_date"] = best_val
|
||||
best_g["_eff_dob"] = best_val or "2010-01-01"
|
||||
|
||||
if merged_notes:
|
||||
best_g["Notes"] = " | ".join(merged_notes)
|
||||
@@ -1513,13 +1598,49 @@ def main():
|
||||
|
||||
print(f"Deduplicated to {len(resolved_gerbils)} unique gerbil records.")
|
||||
|
||||
# Apply age-based death threshold (6.0 years) to all resolved gerbils
|
||||
dt_now = datetime.now()
|
||||
for g in resolved_gerbils:
|
||||
if g.get("Status") != "Deceased" and g.get("Status") != "GivenAway":
|
||||
if not g.get("DateOfDeath") and not g.get("ReceiverContactId"):
|
||||
dob_str = g.get("DateOfBirth")
|
||||
if dob_str:
|
||||
try:
|
||||
dt_dob = datetime.strptime(dob_str, "%Y-%m-%d")
|
||||
age_years = (dt_now - dt_dob).days / 365.25
|
||||
if age_years >= 6.0:
|
||||
g["Status"] = "Deceased"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. Map Gerbils to Litters
|
||||
for g in resolved_gerbils:
|
||||
old_lid = g["_old_scoped_litter_id"]
|
||||
if old_lid in litter_id_map:
|
||||
g["LitterId"] = litter_id_map[old_lid]
|
||||
else:
|
||||
g["LitterId"] = None
|
||||
l_guid = litter_id_map.get(old_lid)
|
||||
g["LitterId"] = l_guid
|
||||
|
||||
if l_guid and l_guid in litter_by_scoped_id:
|
||||
l = litter_by_scoped_id[l_guid]
|
||||
l_date = l.get("Date")
|
||||
if l_date:
|
||||
if not g.get("DateOfBirth"):
|
||||
g["DateOfBirth"] = l_date
|
||||
g["_birth_date"] = l_date
|
||||
g["_eff_dob"] = l_date
|
||||
|
||||
# Apply age-based death threshold (6.0 years) to all resolved gerbils (including newly backfilled ones)
|
||||
if g.get("Status") != "Deceased" and g.get("Status") != "GivenAway":
|
||||
if not g.get("DateOfDeath") and not g.get("ReceiverContactId"):
|
||||
dob_str = g.get("DateOfBirth")
|
||||
if dob_str:
|
||||
try:
|
||||
dt_dob = datetime.strptime(dob_str, "%Y-%m-%d")
|
||||
age_years = (dt_now - dt_dob).days / 365.25
|
||||
if age_years >= 6.0:
|
||||
g["Status"] = "Deceased"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean helper fields
|
||||
del g["_old_scoped_litter_id"]
|
||||
del g["_eff_dob"]
|
||||
@@ -1575,7 +1696,7 @@ def main():
|
||||
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
|
||||
if final_c and final_c["Gender"] in ["male", "unknown"]:
|
||||
# Ensure parent is born before litter if birth date is known
|
||||
if l["Date"] != "0001-01-01" and final_c["DateOfBirth"]:
|
||||
if l["Date"] and final_c["DateOfBirth"]:
|
||||
if final_c["DateOfBirth"] < l["Date"]:
|
||||
valid_candidates.append(final_c)
|
||||
else:
|
||||
@@ -1600,7 +1721,7 @@ def main():
|
||||
continue
|
||||
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
|
||||
if final_c and final_c["Gender"] in ["female", "unknown"]:
|
||||
if l["Date"] != "0001-01-01" and final_c["DateOfBirth"]:
|
||||
if l["Date"] and final_c["DateOfBirth"]:
|
||||
if final_c["DateOfBirth"] < l["Date"]:
|
||||
valid_candidates.append(final_c)
|
||||
else:
|
||||
|
||||
@@ -10,9 +10,9 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
|
||||
- Konflikte zur Klärung: **2**
|
||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
|
||||
- Fotos zugeordnet: **417**
|
||||
- Fotos zugeordnet: **416**
|
||||
- Würfe aus der Wurfchronik: **752**
|
||||
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 167, nur über Geburtsdatum: 103; mehrdeutig: 17)
|
||||
- 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)
|
||||
|
||||
## Zusammenführungs-Schlüssel
|
||||
|
||||
@@ -71,15 +71,38 @@ def main():
|
||||
|
||||
# Load 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:
|
||||
seeds = json.load(f)
|
||||
|
||||
# Map variety name -> Guid
|
||||
variety_map = {}
|
||||
for v in seeds:
|
||||
# UUID is based on SortOrder + 1
|
||||
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
|
||||
variety_map[v["name"].strip().lower()] = variety_id
|
||||
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_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
|
||||
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:
|
||||
status = "GivenAway"
|
||||
else:
|
||||
# Age presumed deceased (>7 years)
|
||||
# Age presumed deceased (>6 years)
|
||||
if dob:
|
||||
dt_dob = datetime.strptime(dob, "%Y-%m-%d")
|
||||
dt_now = datetime.now()
|
||||
age_years = (dt_now - dt_dob).days / 365.25
|
||||
if age_years >= 7.0:
|
||||
if age_years >= 6.0:
|
||||
status = "Deceased"
|
||||
else:
|
||||
status = "Breeding" # default to breeding for resident stock
|
||||
|
||||
@@ -337,6 +337,65 @@ check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
|
||||
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
|
||||
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
|
||||
|
||||
# --- Stammbaum von Danako validation ---
|
||||
danako_path = r"C:\Users\gulum\dev\Wurfchronik_Bilder\Stammbaum von Danako.xlsx"
|
||||
if not os.path.exists(danako_path):
|
||||
danako_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Danako.xlsx"
|
||||
|
||||
if os.path.exists(danako_path):
|
||||
print(f"\nFound Danako stammbaum at {danako_path}, running integration validation...")
|
||||
danako_animals = e.extract_stammbaum(danako_path)
|
||||
danako_by_name = {a["name"]: a for a in danako_animals}
|
||||
|
||||
check("Danako present in Danako sheet", "Danako" in danako_by_name)
|
||||
if "Danako" in danako_by_name:
|
||||
d = danako_by_name["Danako"]
|
||||
check("Danako DOB is 22.08.2018", d["dob"] == "22.08.2018")
|
||||
check("Danako photo matches image7.png", d["photos"] == ["photos/danako-22082018/image7.png"])
|
||||
|
||||
check("Osamu present in Danako sheet", "Osamu" in danako_by_name)
|
||||
if "Osamu" in danako_by_name:
|
||||
o = danako_by_name["Osamu"]
|
||||
check("Osamu DOB is 10.12.2015", o["dob"] == "10.12.2015")
|
||||
check("Osamu photo matches image4.jpeg", o["photos"] == ["photos/osamu-10122015/image4.jpeg"])
|
||||
|
||||
# Check parentRefs of Osamu in Danako sheet
|
||||
o_parents = o.get("parentRefs", [])
|
||||
o_father = next((p for p in o_parents if p.get("roleGuess") == "father"), None)
|
||||
o_mother = next((p for p in o_parents if p.get("roleGuess") == "mother"), None)
|
||||
check("Osamu father is Porter", o_father is not None and o_father["name"] == "Porter")
|
||||
check("Osamu mother is Yuka", o_mother is not None and o_mother["name"] == "Yuka")
|
||||
if o_father:
|
||||
check("Osamu father DOB is 23.05.2015", o_father["dob"] == "23.05.2015")
|
||||
if o_mother:
|
||||
check("Osamu mother DOB is 12.07.2015", o_mother["dob"] == "12.07.2015")
|
||||
|
||||
check("Porter present in Danako sheet", "Porter" in danako_by_name)
|
||||
if "Porter" in danako_by_name:
|
||||
p = danako_by_name["Porter"]
|
||||
check("Porter DOB is 23.05.2015", p["dob"] == "23.05.2015")
|
||||
check("Porter photo matches image6.jpeg", p["photos"] == ["photos/porter-23052015/image6.jpeg"])
|
||||
|
||||
check("Yuka present in Danako sheet", "Yuka" in danako_by_name)
|
||||
if "Yuka" in danako_by_name:
|
||||
y = danako_by_name["Yuka"]
|
||||
check("Yuka DOB is 12.07.2015", y["dob"] == "12.07.2015")
|
||||
check("Yuka photo matches image5.jpeg", y["photos"] == ["photos/yuka-12072015/image5.jpeg"])
|
||||
|
||||
check("Eddward present in Danako sheet", "Eddward" in danako_by_name)
|
||||
if "Eddward" in danako_by_name:
|
||||
ed = danako_by_name["Eddward"]
|
||||
check("Eddward DOB is 18.11.2015", ed["dob"] == "18.11.2015")
|
||||
check("Eddward photo matches image1.jpeg", ed["photos"] == ["photos/eddward-18112015/image1.jpeg"])
|
||||
|
||||
check("Harumi present in Danako sheet", "Harumi" in danako_by_name)
|
||||
if "Harumi" in danako_by_name:
|
||||
h = danako_by_name["Harumi"]
|
||||
check("Harumi DOB is 21.02.2015", h["dob"] == "21.02.2015")
|
||||
check("Harumi photo matches image2.jpeg", h["photos"] == ["photos/harumi-21022015/image2.jpeg"])
|
||||
else:
|
||||
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
|
||||
|
||||
if failed:
|
||||
print(f"\n{failed} test(s) FAILED")
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user