diff --git a/GerbilManager.Tests/ReturnRecordEndpointTests.cs b/GerbilManager.Tests/ReturnRecordEndpointTests.cs new file mode 100644 index 0000000..4529c9c --- /dev/null +++ b/GerbilManager.Tests/ReturnRecordEndpointTests.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace GerbilManager.Tests; + +/// +/// RÜCKNAHMEN (getback_tb): zurückgekommene/zurückgenommene Tiere. +/// - Voller CRUD-Round-Trip (POST/GET[?gerbilId=]/PUT/DELETE). +/// - Validierung: ohne Tier (weder GerbilId noch GerbilName) → 400. +/// - CRITICAL: Rücknahme-Zeilen überleben den Import-Re-Ingest-Wipe +/// (lose, FK-freie GerbilId/FromContactId — wie Feedback). +/// +public class ReturnRecordEndpointTests : IClassFixture +{ + private readonly ApiFactory _factory; + public ReturnRecordEndpointTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Crud_round_trip_create_list_update_delete() + { + var client = _factory.CreateClient(); + + var gerbilId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + // POST + var resp = await client.PostAsJsonAsync("/returns", new + { + gerbilId, + gerbilName = "Krümel", + returnDate = "2026-05-01T00:00:00Z", + returnPrice = 0m, + originalPrice = 15m, + originalSaleDate = "2025-09-01T00:00:00Z", + fromContactId = contactId, + fromContactName = "Familie Meier", + note = "Allergie in der Familie", + }); + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + var id = created.GetProperty("id").GetString()!; + Assert.Equal("Krümel", created.GetProperty("gerbilName").GetString()); + Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString()); + Assert.Equal("Familie Meier", created.GetProperty("fromContactName").GetString()); + + // GET (unfiltered) returns it + var listed = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement; + Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id); + + // GET ?gerbilId= filters + var filtered = JsonDocument.Parse( + await client.GetStringAsync($"/returns?gerbilId={gerbilId}")).RootElement; + Assert.All(filtered.EnumerateArray(), + r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString())); + Assert.Contains(filtered.EnumerateArray(), r => r.GetProperty("id").GetString() == id); + + var other = JsonDocument.Parse( + await client.GetStringAsync($"/returns?gerbilId={Guid.NewGuid()}")).RootElement; + Assert.Empty(other.EnumerateArray()); + + // PUT updates + var putResp = await client.PutAsJsonAsync($"/returns/{id}", new + { + gerbilId, + gerbilName = "Krümel", + returnDate = "2026-05-02T00:00:00Z", + returnPrice = 5m, + note = "Korrigierter Grund", + }); + Assert.Equal(HttpStatusCode.OK, putResp.StatusCode); + var updated = JsonDocument.Parse(await putResp.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Korrigierter Grund", updated.GetProperty("note").GetString()); + Assert.Equal(5m, updated.GetProperty("returnPrice").GetDecimal()); + + // DELETE removes + var delResp = await client.DeleteAsync($"/returns/{id}"); + Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode); + var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement; + Assert.DoesNotContain(afterDelete.EnumerateArray(), r => r.GetProperty("id").GetString() == id); + + // DELETE again → 404 + var delAgain = await client.DeleteAsync($"/returns/{id}"); + Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode); + } + + [Fact] + public async Task Post_rejects_record_without_animal() + { + var client = _factory.CreateClient(); + var resp = await client.PostAsJsonAsync("/returns", new { note = "irgendwas" }); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] + public async Task Put_unknown_id_returns_404() + { + var client = _factory.CreateClient(); + var resp = await client.PutAsJsonAsync($"/returns/{Guid.NewGuid()}", new { gerbilName = "X" }); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + [Fact] + public async Task ReturnRecord_survives_ingest_wipe() + { + var dir = Path.Combine(Path.GetTempPath(), "returns-ingest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var contactId = Guid.NewGuid(); + var fatherId = Guid.NewGuid(); + var motherId = Guid.NewGuid(); + var litterId = Guid.NewGuid(); + + var data = new + { + Contacts = new[] + { + new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null } + }, + Litters = new[] + { + new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" } + }, + Gerbils = new[] + { + Animal(fatherId, "Papa", "male", contactId), + Animal(motherId, "Mama", "female", contactId), + }, + GerbilPhotos = Array.Empty(), + }; + File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data)); + + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase("returns-ingest-" + Guid.NewGuid().ToString("N")) + .Options; + using var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); + + // A return record referencing the gerbil + contact that the wipe will delete. + var recordId = Guid.NewGuid(); + db.ReturnRecords.Add(new ReturnRecord + { + Id = recordId, + GerbilId = fatherId, + GerbilName = "Papa", + ReturnDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), + ReturnPrice = 0m, + FromContactId = contactId, + FromContactName = "Test Breeder", + Note = "kam zurück", + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", dir } }) + .Build(); + + var result = await new IngestResolvedService(db, config, null!).RunAsync(); + Assert.Contains("Ingestion successful!", result); + + // The gerbils/contacts were wiped & recreated, but the return record is untouched. + var survivor = await db.ReturnRecords.SingleAsync(r => r.Id == recordId); + Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated + Assert.Equal(contactId, survivor.FromContactId); + Assert.Equal("Papa", survivor.GerbilName); + Assert.Equal("Test Breeder", survivor.FromContactName); + Assert.Equal(1, await db.ReturnRecords.CountAsync()); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } + + private static object Animal(Guid id, string name, string gender, Guid contactId) => new + { + Id = id, + Name = name, + Gender = gender, + Status = "Breeding", + LitterId = (Guid?)null, + OriginContactId = contactId, + ReceiverContactId = (Guid?)null, + EnclosureId = (Guid?)null, + ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), + DateOfBirth = "2025-01-01", + DateOfDeath = (string?)null, + CauseOfDeath = (string?)null, + GoHomeDate = (string?)null, + Genotype = "aa CC DD EE GG PP spsp rere", + Notes = "", + ImportSource = "docx-export", + ExternalRef = "ext-" + name, + RawImportData = "{}", + OriginBreeder = "Test Zucht", + NameSearch = name.ToLowerInvariant(), + CharacterTraits = Array.Empty(), + CharacterNote = (string?)null, + IsDeaf = false, + IsResident = true, + }; +} diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index a620842..3105eef 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -25,6 +25,7 @@ public class ApplicationContext : DbContext public DbSet Requests => Set(); public DbSet MailSettings => Set(); public DbSet Feedback => Set(); + public DbSet ReturnRecords => Set(); // Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key), // so it can never drift from Name regardless of which code path mutates the entity. @@ -212,6 +213,18 @@ public class ApplicationContext : DbContext e.HasIndex(f => f.CreatedAt); }); + // RÜCKNAHMEN (getback_tb): wie Feedback bewusst beziehungsfrei. GerbilId/ + // FromContactId sind einfache nullable Guid-Spalten (keine Navigation → EF legt + // KEINEN Foreign Key an), damit der Import-Re-Ingest-Wipe von Gerbils/Contacts + // diese Zeilen weder löscht noch bricht. Sie überleben den Re-Ingest. + modelBuilder.Entity(e => + { + e.Property(r => r.ReturnPrice).HasPrecision(10, 2); + e.Property(r => r.OriginalPrice).HasPrecision(10, 2); + e.HasIndex(r => r.GerbilId); + e.HasIndex(r => r.CreatedAt); + }); + // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). if (isNpgsql) { diff --git a/GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs b/GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs new file mode 100644 index 0000000..fd7f2c3 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs @@ -0,0 +1,28 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// RÜCKNAHME: Payload für POST/PUT /returns. + public record ReturnRecordInput( + Guid? GerbilId, + string? GerbilName, + DateTime? ReturnDate, + decimal? ReturnPrice, + decimal? OriginalPrice, + DateTime? OriginalSaleDate, + Guid? FromContactId, + string? FromContactName, + string? Note); + + /// RÜCKNAHME: Antwort-DTO für einen gespeicherten Rücknahme-Datensatz. + public record ReturnRecordDto( + Guid Id, + Guid? GerbilId, + string? GerbilName, + DateTime? ReturnDate, + decimal? ReturnPrice, + decimal? OriginalPrice, + DateTime? OriginalSaleDate, + Guid? FromContactId, + string? FromContactName, + string? Note, + DateTimeOffset CreatedAt); +} diff --git a/GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs new file mode 100644 index 0000000..1277fc9 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs @@ -0,0 +1,104 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// RÜCKNAHMEN (RPRO3 getback_tb): zurückgekommene/zurückgenommene Tiere. + /// GET /returns[?gerbilId=] -> Liste, neueste zuerst (optional nach Tier gefiltert). + /// POST /returns -> Rücknahme erfassen, 201. + /// PUT /returns/{id} -> Rücknahme aktualisieren, 200. + /// DELETE /returns/{id} -> Rücknahme löschen, 204. + /// Entkoppelt von Gerbils/Contacts (lose nullable Guid-Spalten, kein FK), daher + /// überleben Zeilen den Import-Re-Ingest-Wipe — wie Feedback. + /// + public static class ReturnRecordEndpoints + { + public static IEndpointRouteBuilder MapReturnRecordEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/returns").WithTags("Returns"); + + group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) => + { + var query = db.ReturnRecords.AsNoTracking(); + if (gerbilId is { } gid) + query = query.Where(r => r.GerbilId == gid); + // In-Memory sortieren: SQLite (Test-Host) kann nicht nach DateTimeOffset ORDER BY-en. + var rows = await query.ToListAsync(); + return TypedResults.Ok(rows + .OrderByDescending(r => r.ReturnDate ?? DateTime.MinValue) + .ThenByDescending(r => r.CreatedAt) + .Select(ToDto) + .ToList()); + }); + + group.MapPost("/", async Task, BadRequest>> ( + ReturnRecordInput input, ApplicationContext db) => + { + if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName)) + return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden."); + + var entity = new ReturnRecord + { + Id = Guid.NewGuid(), + GerbilId = input.GerbilId, + GerbilName = Trimmed(input.GerbilName), + ReturnDate = input.ReturnDate, + ReturnPrice = input.ReturnPrice, + OriginalPrice = input.OriginalPrice, + OriginalSaleDate = input.OriginalSaleDate, + FromContactId = input.FromContactId, + FromContactName = Trimmed(input.FromContactName), + Note = Trimmed(input.Note), + CreatedAt = DateTimeOffset.UtcNow, + }; + db.ReturnRecords.Add(entity); + await db.SaveChangesAsync(); + return TypedResults.Created($"/returns/{entity.Id}", ToDto(entity)); + }); + + group.MapPut("/{id:guid}", async Task, NotFound, BadRequest>> ( + Guid id, ReturnRecordInput input, ApplicationContext db) => + { + var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id); + if (entity is null) return TypedResults.NotFound(); + + if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName)) + return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden."); + + entity.GerbilId = input.GerbilId; + entity.GerbilName = Trimmed(input.GerbilName); + entity.ReturnDate = input.ReturnDate; + entity.ReturnPrice = input.ReturnPrice; + entity.OriginalPrice = input.OriginalPrice; + entity.OriginalSaleDate = input.OriginalSaleDate; + entity.FromContactId = input.FromContactId; + entity.FromContactName = Trimmed(input.FromContactName); + entity.Note = Trimmed(input.Note); + await db.SaveChangesAsync(); + return TypedResults.Ok(ToDto(entity)); + }); + + group.MapDelete("/{id:guid}", async Task> ( + Guid id, ApplicationContext db) => + { + var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id); + if (entity is null) return TypedResults.NotFound(); + db.ReturnRecords.Remove(entity); + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + private static string? Trimmed(string? s) => + string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + private static ReturnRecordDto ToDto(ReturnRecord r) => + new(r.Id, r.GerbilId, r.GerbilName, r.ReturnDate, r.ReturnPrice, r.OriginalPrice, + r.OriginalSaleDate, r.FromContactId, r.FromContactName, r.Note, r.CreatedAt); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs new file mode 100644 index 0000000..92591b5 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs @@ -0,0 +1,1595 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260622201557_AddReturnRecord")] + partial class AddReturnRecord + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + NameSuffix = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "REW", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg pp spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromContactId") + .HasColumnType("uuid"); + + b.Property("FromContactName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OriginalPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("OriginalSaleDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GerbilId"); + + b.ToTable("ReturnRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Page", null) + .WithMany("Blocks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .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") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact") + .WithMany() + .HasForeignKey("AssignedContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.cs b/GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.cs new file mode 100644 index 0000000..1008a43 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddReturnRecord : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReturnRecords", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GerbilId = table.Column(type: "uuid", nullable: true), + GerbilName = table.Column(type: "text", nullable: true), + ReturnDate = table.Column(type: "timestamp with time zone", nullable: true), + ReturnPrice = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true), + OriginalPrice = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true), + OriginalSaleDate = table.Column(type: "timestamp with time zone", nullable: true), + FromContactId = table.Column(type: "uuid", nullable: true), + FromContactName = table.Column(type: "text", nullable: true), + Note = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReturnRecords", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReturnRecords_CreatedAt", + table: "ReturnRecords", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ReturnRecords_GerbilId", + table: "ReturnRecords", + column: "GerbilId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReturnRecords"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 725b04b..5710d76 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -1284,6 +1284,53 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("Requests"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromContactId") + .HasColumnType("uuid"); + + b.Property("FromContactName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OriginalPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("OriginalSaleDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GerbilId"); + + b.ToTable("ReturnRecords"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => { b.Property("Id") diff --git a/GerbilManagerWebAPI/Models/ReturnRecord.cs b/GerbilManagerWebAPI/Models/ReturnRecord.cs new file mode 100644 index 0000000..23bc0eb --- /dev/null +++ b/GerbilManagerWebAPI/Models/ReturnRecord.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// RÜCKNAHME (RPRO3 getback_tb): ein bereits abgegebenes Tier kommt zur Züchterin + /// zurück. Bewusst vom restlichen Modell entkoppelt — GerbilId/FromContactId sind + /// einfache nullable Guid-Spalten (KEINE Foreign Keys), genau wie bei . + /// Dadurch überleben Rücknahmen den Import-Re-Ingest-Wipe (IngestResolvedService löscht + /// Gerbils/Contacts, ohne diese Tabelle anzufassen). Der erfasste GerbilName hält den + /// Datensatz auch dann lesbar, wenn das referenzierte Tier neu eingespielt/entfernt wurde. + /// + public class ReturnRecord + { + [Key] + public Guid Id { get; set; } + + /// Lose Referenz (kein FK) auf das zurückgekommene Tier (_TID). + public Guid? GerbilId { get; set; } + + /// Erfasster Tiername — überlebt einen Ingest-Wipe (bleibt lesbar). + public string? GerbilName { get; set; } + + /// Rücknahmedatum (_ZAM). + public DateTime? ReturnDate { get; set; } + + /// Rücknahmepreis (_ZPREIS) — was die Züchterin bei der Rücknahme zahlte/erstattete. + public decimal? ReturnPrice { get; set; } + + /// Ursprünglicher Abgabepreis (_PREIS), zur Historie mitgeführt. + public decimal? OriginalPrice { get; set; } + + /// Ursprüngliches Abgabedatum (_AM), zur Historie mitgeführt. + public DateTime? OriginalSaleDate { get; set; } + + /// Lose Referenz (kein FK) auf den Kontakt, von dem das Tier zurückkam (_ABN). + public Guid? FromContactId { get; set; } + + /// Erfasster Kontaktname — überlebt einen Ingest-Wipe (bleibt lesbar). + public string? FromContactName { get; set; } + + /// Freitext-Grund/Notiz zur Rücknahme (_BEM). + public string? Note { get; set; } + + /// Server-seitige Anlagezeit. + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index ecb8493..5dc70f4 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -127,6 +127,7 @@ app.MapCmsEndpoints(); app.MapRequestEndpoints(); app.MapNamesEndpoints(); app.MapFeedbackEndpoints(); +app.MapReturnRecordEndpoints(); app.Run(); diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 78faa7a..f94231b 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -416,6 +416,42 @@ export async function installMockApi(page: Page): Promise { return json(route, 405) } + // RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns, GET optional ?gerbilId=). + // Eigene Route (kein Gridify-Paging — die API liefert ein flaches Array). + const ret = path.match(/^\/returns(?:\/([^/]+))?$/) + if (ret) { + const retId = ret[1] ? decodeURIComponent(ret[1]) : null + if (!retId) { + if (method === 'GET') { + const gid = url.searchParams.get('gerbilId') + const rows = gid ? db.returns.filter((r) => r.gerbilId === gid) : db.returns + return json(route, 200, [...rows].reverse()) + } + if (method === 'POST') { + const body = request.postDataJSON() as Row + if (!body.gerbilId && !body.gerbilName) { + return json(route, 400, 'Es muss ein Tier angegeben werden.') + } + const created = { id: newId('return'), ...body, createdAt: new Date().toISOString() } + db.returns.push(created) + return json(route, 201, created) + } + return json(route, 405) + } + const idx = db.returns.findIndex((r) => r.id === retId) + if (method === 'PUT') { + if (idx < 0) return json(route, 404, { title: 'Not Found' }) + Object.assign(db.returns[idx], request.postDataJSON() as Row) + return json(route, 200, db.returns[idx]) + } + if (method === 'DELETE') { + if (idx < 0) return json(route, 404, { title: 'Not Found' }) + db.returns.splice(idx, 1) + return json(route, 204) + } + return json(route, 405) + } + // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index b07cefe..eae50eb 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -78,6 +78,8 @@ export interface MockDb { namesConfigured: boolean // FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback) feedback: Record[] + // RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns) + returns: Record[] } function gerbil( @@ -376,5 +378,6 @@ export function seedDb(): MockDb { saleAdConfigured: true, namesConfigured: true, feedback: [], + returns: [], } } diff --git a/gerbil-manager-web/e2e/ruecknahmen.spec.ts b/gerbil-manager-web/e2e/ruecknahmen.spec.ts new file mode 100644 index 0000000..45d038a --- /dev/null +++ b/gerbil-manager-web/e2e/ruecknahmen.spec.ts @@ -0,0 +1,59 @@ +/** RÜCKNAHMEN: Verwaltungsseite für zurückgekommene Tiere (/ruecknahmen). */ +import { de, expect, skipUnlessMock, test } from './fixtures' + +const t = de.pages.ruecknahmen + +test('Rücknahmen: Liste ist anfangs leer und das Formular lässt sich öffnen', async ({ page }) => { + skipUnlessMock() + await page.goto('/ruecknahmen') + + await expect(page.getByRole('heading', { name: t.title })).toBeVisible() + await expect(page.getByText(t.empty)).toBeVisible() + + await page.getByRole('button', { name: t.newButton }).click() + await expect(page.getByRole('heading', { name: t.formTitle })).toBeVisible() +}) + +test('Rücknahmen: erfassen über Formular speichert und zeigt den Eintrag', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/ruecknahmen') + + await page.getByRole('button', { name: t.newButton }).click() + + // Tier + Kontakt aus den Dropdowns wählen, Datum + Grund eintragen. + await page.getByRole('combobox', { name: t.fields.gerbil, exact: true }).selectOption({ label: 'Krümel' }) + await page.getByRole('textbox', { name: t.fields.returnDate, exact: true }).fill('2026-05-01') + await page.getByRole('combobox', { name: t.fields.fromContact, exact: true }).selectOption({ label: 'Zoohandlung Meier' }) + await page.getByRole('textbox', { name: t.fields.note, exact: true }).fill('Allergie in der Familie') + + await page.getByRole('button', { name: t.save }).click() + + // Eintrag erscheint in der Liste mit verlinktem Tiernamen. + const card = page.locator('.gerbil-card', { hasText: 'Krümel' }) + await expect(card).toBeVisible() + await expect(card).toContainText('Allergie in der Familie') + await expect(card.getByRole('link', { name: 'Krümel' })).toBeVisible() + + // Im Mock persistiert (gerbilId + note). + expect(mockDb).not.toBeNull() + const rows = mockDb!.returns + expect(rows.length).toBe(1) + expect(rows[0]).toMatchObject({ gerbilId: 'kruemel', note: 'Allergie in der Familie' }) +}) + +test('Rücknahmen: Eintrag löschen entfernt ihn aus der Liste', async ({ page, mockDb }) => { + skipUnlessMock() + // Einen Eintrag über die UI anlegen (Fixture erstellt die DB frisch pro Test). + await page.goto('/ruecknahmen') + await page.getByRole('button', { name: t.newButton }).click() + await page.getByRole('combobox', { name: t.fields.gerbil, exact: true }).selectOption({ label: 'Krümel' }) + await page.getByRole('button', { name: t.save }).click() + await expect(page.locator('.gerbil-card', { hasText: 'Krümel' })).toBeVisible() + + // Löschen bestätigen. + page.on('dialog', (d) => d.accept()) + await page.locator('.gerbil-card', { hasText: 'Krümel' }).getByRole('button', { name: t.delete }).click() + + await expect(page.getByText(t.empty)).toBeVisible() + expect(mockDb!.returns.length).toBe(0) +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 6c1af9a..d37e7a1 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -27,6 +27,7 @@ import WebseiteEditorPage from './pages/WebseiteEditorPage' import WebseiteVorschauPage from './pages/WebseiteVorschauPage' import AnfragenPage from './pages/AnfragenPage' import AnfrageDetailPage from './pages/AnfrageDetailPage' +import RuecknahmenPage from './pages/RuecknahmenPage' function BeckenRedirect() { const { '*': splat } = useParams() @@ -90,6 +91,8 @@ export default function App() { } /> } /> + {/* RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere */} + } /> } /> diff --git a/gerbil-manager-web/src/api/returns.ts b/gerbil-manager-web/src/api/returns.ts new file mode 100644 index 0000000..bf44034 --- /dev/null +++ b/gerbil-manager-web/src/api/returns.ts @@ -0,0 +1,48 @@ +/** RÜCKNAHMEN: API-Client für zurückgenommene Tiere (/returns). */ +import { api } from './client' + +const RESOURCE = '/returns' + +/** Payload für POST/PUT /returns. */ +export interface ReturnRecordInput { + gerbilId?: string | null + gerbilName?: string | null + returnDate?: string | null + returnPrice?: number | null + originalPrice?: number | null + originalSaleDate?: string | null + fromContactId?: string | null + fromContactName?: string | null + note?: string | null +} + +export interface ReturnRecord { + id: string + gerbilId: string | null + gerbilName: string | null + returnDate: string | null + returnPrice: number | null + originalPrice: number | null + originalSaleDate: string | null + fromContactId: string | null + fromContactName: string | null + note: string | null + createdAt: string +} + +export function listReturns(gerbilId?: string): Promise { + const q = gerbilId ? `?gerbilId=${encodeURIComponent(gerbilId)}` : '' + return api.get(`${RESOURCE}${q}`) +} + +export function createReturn(body: ReturnRecordInput): Promise { + return api.post(RESOURCE, body) +} + +export function updateReturn(id: string, body: ReturnRecordInput): Promise { + return api.put(`${RESOURCE}/${id}`, body) +} + +export function deleteReturn(id: string): Promise { + return api.delete(`${RESOURCE}/${id}`) +} diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 61cde93..f21c26d 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -26,6 +26,8 @@ const SECONDARY: NavItem[] = [ { to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, // INBOX-1: Anfragen-Posteingang { to: '/anfragen', label: de.nav.requests, icon: '📨' }, + // RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere + { to: '/ruecknahmen', label: de.nav.returns, icon: '↩️' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' }, // FEAT-13: Abgabeverträge + Zuchtprofil { to: '/vertraege', label: de.nav.contracts, icon: '📄' }, diff --git a/gerbil-manager-web/src/pages/RuecknahmenPage.tsx b/gerbil-manager-web/src/pages/RuecknahmenPage.tsx new file mode 100644 index 0000000..976c719 --- /dev/null +++ b/gerbil-manager-web/src/pages/RuecknahmenPage.tsx @@ -0,0 +1,353 @@ +/** + * RÜCKNAHMEN: Verwaltungsseite für zurückgekommene/zurückgenommene Tiere (/ruecknahmen). + * Liste + Inline-Formular zum Erfassen/Bearbeiten. Tier-/Kontakt-Bezüge als Links. + */ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { de } from '../strings/de' +import { + createReturn, + deleteReturn, + listReturns, + updateReturn, + type ReturnRecord, + type ReturnRecordInput, +} from '../api/returns' +import { listGerbils } from '../api/gerbils' +import { listContactsPaged } from '../api/contacts' +import { useApi, useMutation } from '../hooks/useApi' +import { formatDate } from '../format/labels' + +const EMPTY_FORM = { + gerbilId: '', + gerbilName: '', + returnDate: '', + returnPrice: '', + originalPrice: '', + originalSaleDate: '', + fromContactId: '', + fromContactName: '', + note: '', +} +type FormState = typeof EMPTY_FORM + +function toInput(form: FormState): ReturnRecordInput { + const num = (s: string) => (s.trim() === '' ? null : Number(s)) + return { + gerbilId: form.gerbilId || null, + gerbilName: form.gerbilName.trim() || null, + returnDate: form.returnDate ? `${form.returnDate}T00:00:00Z` : null, + returnPrice: num(form.returnPrice), + originalPrice: num(form.originalPrice), + originalSaleDate: form.originalSaleDate ? `${form.originalSaleDate}T00:00:00Z` : null, + fromContactId: form.fromContactId || null, + fromContactName: form.fromContactName.trim() || null, + note: form.note.trim() || null, + } +} + +/** ISO/Date-Wert → "YYYY-MM-DD" für . */ +function isoDateInput(value: string | null): string { + if (!value) return '' + return value.slice(0, 10) +} + +function fromRecord(r: ReturnRecord): FormState { + return { + gerbilId: r.gerbilId ?? '', + gerbilName: r.gerbilName ?? '', + returnDate: isoDateInput(r.returnDate), + returnPrice: r.returnPrice == null ? '' : String(r.returnPrice), + originalPrice: r.originalPrice == null ? '' : String(r.originalPrice), + originalSaleDate: isoDateInput(r.originalSaleDate), + fromContactId: r.fromContactId ?? '', + fromContactName: r.fromContactName ?? '', + note: r.note ?? '', + } +} + +function formatPrice(value: number | null): string { + if (value == null) return '—' + return `${value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €` +} + +export default function RuecknahmenPage() { + const t = de.pages.ruecknahmen + + const returns = useApi(() => listReturns(), []) + const gerbils = useApi( + () => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), + [], + ) + const contacts = useApi( + () => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), + [], + ) + + const gerbilName = useMemo( + () => new Map((gerbils.data?.items ?? []).map((g) => [g.id, g.name])), + [gerbils.data], + ) + const contactName = useMemo( + () => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])), + [contacts.data], + ) + + const [form, setForm] = useState(EMPTY_FORM) + const [editingId, setEditingId] = useState(null) + const [showForm, setShowForm] = useState(false) + const [validationError, setValidationError] = useState(null) + + const save = useMutation((body: ReturnRecordInput) => + editingId ? updateReturn(editingId, body) : createReturn(body), + ) + const removal = useMutation((id: string) => deleteReturn(id)) + + function set(key: K, value: string) { + setForm((f) => ({ ...f, [key]: value })) + } + + function startNew() { + setForm(EMPTY_FORM) + setEditingId(null) + setValidationError(null) + setShowForm(true) + } + + function startEdit(r: ReturnRecord) { + setForm(fromRecord(r)) + setEditingId(r.id) + setValidationError(null) + setShowForm(true) + } + + function cancelForm() { + setShowForm(false) + setEditingId(null) + setForm(EMPTY_FORM) + setValidationError(null) + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault() + setValidationError(null) + if (!form.gerbilId && !form.gerbilName.trim()) { + setValidationError(t.validationNoAnimal) + return + } + const result = await save.run(toInput(form)) + if (result.ok) { + cancelForm() + returns.reload() + } + } + + async function onDelete(id: string) { + if (!window.confirm(t.confirmDelete)) return + const result = await removal.run(id) + if (result.ok) returns.reload() + } + + if (returns.loading) return {de.common.loading} + if (returns.error) { + return ( + + {t.title} + + {returns.error} + + {de.common.retry} + + + + ) + } + + const items = returns.data ?? [] + + return ( + + + + {t.title} + {items.length === 0 ? t.subtitle : t.countText(items.length)} + + {!showForm && ( + + + {t.newButton} + + + )} + + + {showForm && ( + + {editingId ? t.editTitle : t.formTitle} + + + {t.fields.gerbil} + set('gerbilId', e.target.value)}> + {t.gerbilPlaceholder} + {(gerbils.data?.items ?? []).map((g) => ( + + {g.name} + + ))} + + + + + {t.fields.gerbilName} + set('gerbilName', e.target.value)} + /> + {t.fields.gerbilNameHint} + + + + {t.fields.returnDate} + set('returnDate', e.target.value)} + /> + + + + {t.fields.returnPrice} + set('returnPrice', e.target.value)} + /> + + + + {t.fields.originalPrice} + set('originalPrice', e.target.value)} + /> + + + + {t.fields.originalSaleDate} + set('originalSaleDate', e.target.value)} + /> + + + + {t.fields.fromContact} + set('fromContactId', e.target.value)}> + {t.contactPlaceholder} + {(contacts.data?.items ?? []).map((c) => ( + + {c.name} + + ))} + + + + + {t.fields.fromContactName} + set('fromContactName', e.target.value)} + /> + {t.fields.fromContactNameHint} + + + + {t.fields.note} + set('note', e.target.value)} + /> + + + {validationError && {validationError}} + {save.error && {t.saveError}} + + + + {save.pending ? t.saving : t.save} + + + {t.cancel} + + + + )} + + {removal.error && {removal.error}} + + {items.length === 0 ? ( + {t.empty} + ) : ( + + {items.map((r) => { + const displayName = r.gerbilId + ? (gerbilName.get(r.gerbilId) ?? r.gerbilName ?? t.unknownAnimal) + : (r.gerbilName ?? t.unknownAnimal) + const displayContact = r.fromContactId + ? (contactName.get(r.fromContactId) ?? r.fromContactName) + : r.fromContactName + return ( + + + {r.gerbilId ? ( + {displayName} + ) : ( + displayName + )} + + + {t.returnedOn} {formatDate(isoDateInput(r.returnDate) || null)} + {displayContact && ( + <> + {' · '} + {t.from}{' '} + {r.fromContactId ? ( + {displayContact} + ) : ( + displayContact + )} + > + )} + {r.returnPrice != null && <> · {formatPrice(r.returnPrice)}>} + {r.note && <> · {r.note}>} + + + startEdit(r)}> + {t.edit} + + onDelete(r.id)} + disabled={removal.pending} + > + {t.delete} + + + + ) + })} + + )} + + ) +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index a146723..cf9c9a4 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -27,6 +27,8 @@ export const de = { settings: 'Einstellungen', // INBOX-1 (Kelly): Anfragen-Posteingang requests: 'Anfragen', + // RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere + returns: 'Rücknahmen', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen', mainNavigation: 'Hauptnavigation', @@ -820,6 +822,48 @@ export const de = { }, }, }, + // ── RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere (getback_tb) ── + ruecknahmen: { + title: 'Rücknahmen', + subtitle: 'Tiere, die zur Zucht zurückgekommen sind', + countText: (n: number) => + n === 1 ? '1 Rücknahme erfasst' : `${n} Rücknahmen erfasst`, + empty: 'Noch keine Rücknahmen erfasst.', + newButton: 'Rücknahme erfassen', + // Formular + formTitle: 'Rücknahme erfassen', + editTitle: 'Rücknahme bearbeiten', + fields: { + gerbil: 'Tier', + gerbilName: 'Tiername', + gerbilNameHint: 'Falls das Tier nicht in der Liste steht, hier den Namen eintragen.', + returnDate: 'Rücknahmedatum', + returnPrice: 'Rücknahmepreis (€)', + originalPrice: 'Ursprünglicher Abgabepreis (€)', + originalSaleDate: 'Ursprüngliches Abgabedatum', + fromContact: 'Zurück von (Kontakt)', + fromContactName: 'Name (frei)', + fromContactNameHint: 'Falls der Kontakt nicht in der Liste steht, hier den Namen eintragen.', + note: 'Grund / Notiz', + notePlaceholder: 'Warum kam das Tier zurück?', + }, + gerbilPlaceholder: '— Tier wählen —', + contactPlaceholder: '— Kontakt wählen —', + // Listen-Karte + returnedOn: 'zurück am', + from: 'von', + unknownAnimal: '(unbekanntes Tier)', + // Aktionen + save: 'Speichern', + saving: 'Wird gespeichert …', + edit: 'Bearbeiten', + cancel: 'Abbrechen', + delete: 'Löschen', + confirmDelete: 'Diese Rücknahme wirklich löschen?', + validationNoAnimal: 'Bitte ein Tier wählen oder einen Tiernamen eintragen.', + saveSuccess: 'Rücknahme gespeichert.', + saveError: 'Rücknahme konnte nicht gespeichert werden.', + }, }, // ── HELP-1: In-App-Anleitung ── hilfe: {
{de.common.loading}
{items.length === 0 ? t.subtitle : t.countText(items.length)}
{t.empty}