diff --git a/GerbilManager.Tests/SaleReservationEndpointTests.cs b/GerbilManager.Tests/SaleReservationEndpointTests.cs new file mode 100644 index 0000000..ec7d1e3 --- /dev/null +++ b/GerbilManager.Tests/SaleReservationEndpointTests.cs @@ -0,0 +1,214 @@ +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; + +/// +/// ABGABE-STATUS (Reservierungs-/Abgabe-Status): the pre-handover pipeline +/// verfügbar → reserviert → abgegeben. +/// - Full CRUD round-trip via /reservations. +/// - GET ?gerbilId= filters to one animal. +/// - Invalid status falls back to "verfuegbar". +/// - CRITICAL: rows survive the import re-ingest wipe (loose, FK-free GerbilId/ReservedForContactId). +/// +public class SaleReservationEndpointTests : IClassFixture +{ + private readonly ApiFactory _factory; + public SaleReservationEndpointTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Reservation_crud_lifecycle_create_reserve_handover_delete() + { + var client = _factory.CreateClient(); + var gerbilId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + // Create -> defaults to verfuegbar. + var create = await client.PostAsJsonAsync("/reservations", new + { + gerbilId, + gerbilName = "Pippa", + }); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement; + var id = created.GetProperty("id").GetString(); + Assert.False(string.IsNullOrEmpty(id)); + Assert.Equal("verfuegbar", created.GetProperty("status").GetString()); + Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString()); + Assert.Equal("Pippa", created.GetProperty("gerbilName").GetString()); + + // List contains it. + var listed = JsonDocument.Parse(await client.GetStringAsync("/reservations")).RootElement; + Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id); + + // Filter by gerbilId returns exactly this row. + var byGerbil = JsonDocument.Parse(await client.GetStringAsync($"/reservations?gerbilId={gerbilId}")).RootElement; + Assert.All(byGerbil.EnumerateArray(), r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString())); + Assert.Contains(byGerbil.EnumerateArray(), r => r.GetProperty("id").GetString() == id); + + // PUT -> reserve for a contact with an appointment + price. + var reserve = await client.PutAsJsonAsync($"/reservations/{id}", new + { + status = "reserviert", + reservedForContactId = contactId, + contactName = "Familie Huber", + appointmentDate = "2026-07-01T10:00:00Z", + price = 25.50m, + note = "Käfig wird mitgebracht.", + }); + Assert.Equal(HttpStatusCode.OK, reserve.StatusCode); + var reserved = JsonDocument.Parse(await reserve.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("reserviert", reserved.GetProperty("status").GetString()); + Assert.Equal(contactId.ToString(), reserved.GetProperty("reservedForContactId").GetString()); + Assert.Equal("Familie Huber", reserved.GetProperty("contactName").GetString()); + Assert.Equal(25.50m, reserved.GetProperty("price").GetDecimal()); + + // PUT -> hand over. + var handover = await client.PutAsJsonAsync($"/reservations/{id}", new + { + status = "abgegeben", + reservedForContactId = contactId, + handedOverDate = "2026-07-01T10:30:00Z", + }); + Assert.Equal(HttpStatusCode.OK, handover.StatusCode); + var handed = JsonDocument.Parse(await handover.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("abgegeben", handed.GetProperty("status").GetString()); + Assert.NotEqual(JsonValueKind.Null, handed.GetProperty("handedOverDate").ValueKind); + + // Delete -> 204, then 404. + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/reservations/{id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/reservations/{id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/reservations/{id}", new { status = "verfuegbar" })).StatusCode); + } + + [Fact] + public async Task Post_with_unknown_status_falls_back_to_verfuegbar() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.NewGuid(), status = "bananen" }); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var dto = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("verfuegbar", dto.GetProperty("status").GetString()); + } + + [Fact] + public async Task Post_without_gerbilId_is_rejected() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.Empty, status = "reserviert" }); + Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode); + } + + [Fact] + public async Task Reservation_survives_ingest_wipe() + { + // Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests). + var dir = Path.Combine(Path.GetTempPath(), "reservation-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("reservation-ingest-" + Guid.NewGuid().ToString("N")) + .Options; + using var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); + + // A reservation referencing the gerbil + contact that the wipe will delete. + var resId = Guid.NewGuid(); + db.SaleReservations.Add(new SaleReservation + { + Id = resId, + GerbilId = fatherId, + GerbilName = "Papa", + Status = "reserviert", + ReservedForContactId = contactId, + ContactName = "Test Breeder", + AppointmentDate = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), + Price = 30m, + Note = "Abholung am Wochenende.", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", dir } }) + .Build(); + + // Run the ingest wipe + reload. + var result = await new IngestResolvedService(db, config, null!).RunAsync(); + Assert.Contains("Ingestion successful!", result); + + // Gerbils/contacts were wiped & re-created, but the reservation is untouched. + var survivor = await db.SaleReservations.SingleAsync(r => r.Id == resId); + Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved + Assert.Equal(contactId, survivor.ReservedForContactId); + Assert.Equal("Papa", survivor.GerbilName); + Assert.Equal("Test Breeder", survivor.ContactName); + Assert.Equal("reserviert", survivor.Status); + Assert.Equal(30m, survivor.Price); + Assert.Equal(1, await db.SaleReservations.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..e0ff6f7 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 SaleReservations => 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,16 @@ public class ApplicationContext : DbContext e.HasIndex(f => f.CreatedAt); }); + // ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback). + // GerbilId/ReservedForContactId are plain Guid columns (no navigation properties → + // EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never + // cascades into — or breaks — reservation rows. They survive re-ingest by design. + modelBuilder.Entity(e => + { + e.Property(r => r.Price).HasPrecision(10, 2); + e.HasIndex(r => r.GerbilId); + }); + // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). if (isNpgsql) { diff --git a/GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs b/GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs new file mode 100644 index 0000000..ea4af80 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs @@ -0,0 +1,43 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// ABGABE-STATUS: response DTO for a stored reservation/sale status. + public record SaleReservationDto( + Guid Id, + Guid GerbilId, + string? GerbilName, + string Status, + Guid? ReservedForContactId, + string? ContactName, + DateTime? AppointmentDate, + decimal? Price, + string? Note, + DateTime? HandedOverDate, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + + /// ABGABE-STATUS: payload for POST /reservations. + public record SaleReservationInput( + Guid GerbilId, + string? GerbilName, + string? Status, + Guid? ReservedForContactId, + string? ContactName, + DateTime? AppointmentDate, + decimal? Price, + string? Note, + DateTime? HandedOverDate); + + /// + /// ABGABE-STATUS: payload for PUT /reservations/{id}. All fields optional — only the + /// provided ones are changed. A null/blank Status is ignored. + /// + public record SaleReservationUpdate( + string? GerbilName, + string? Status, + Guid? ReservedForContactId, + string? ContactName, + DateTime? AppointmentDate, + decimal? Price, + string? Note, + DateTime? HandedOverDate); +} diff --git a/GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs b/GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs new file mode 100644 index 0000000..0bd777e --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/SaleReservationEndpoints.cs @@ -0,0 +1,124 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale + /// pipeline — verfügbar → reserviert → abgegeben. + /// GET /reservations -> list all, newest-updated first. + /// GET /reservations?gerbilId= -> the (single) reservation status for one animal, if any. + /// POST /reservations -> create/establish a status, returns 201. + /// PUT /reservations/{id} -> change status / reservation details. 404 on missing id. + /// DELETE /reservations/{id} -> remove. 404 on missing id. + /// Decoupled from gerbils/contacts (loose nullable Guid columns, no FK), so rows survive + /// the import re-ingest wipe — like Feedback. + /// + public static class SaleReservationEndpoints + { + private static readonly string[] AllowedStatuses = { "verfuegbar", "reserviert", "abgegeben" }; + + /// Normalize a status string to one of the three canonical values; default "verfuegbar". + private static string NormalizeStatus(string? raw) + { + var s = raw?.Trim().ToLowerInvariant(); + return AllowedStatuses.Contains(s) ? s! : "verfuegbar"; + } + + public static IEndpointRouteBuilder MapSaleReservationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/reservations").WithTags("Reservations"); + + group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) => + { + // Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column. + var rows = await db.SaleReservations.AsNoTracking().ToListAsync(); + var filtered = gerbilId is { } gid + ? rows.Where(r => r.GerbilId == gid) + : rows; + return TypedResults.Ok(filtered + .OrderByDescending(r => r.UpdatedAt) + .Select(ToDto) + .ToList()); + }); + + group.MapPost("/", async Task, BadRequest>> ( + SaleReservationInput input, ApplicationContext db) => + { + if (input.GerbilId == Guid.Empty) + return TypedResults.BadRequest("GerbilId ist erforderlich."); + + var now = DateTimeOffset.UtcNow; + var entity = new SaleReservation + { + Id = Guid.NewGuid(), + GerbilId = input.GerbilId, + GerbilName = Trim(input.GerbilName), + Status = NormalizeStatus(input.Status), + ReservedForContactId = input.ReservedForContactId, + ContactName = Trim(input.ContactName), + AppointmentDate = input.AppointmentDate, + Price = input.Price, + Note = Trim(input.Note), + HandedOverDate = input.HandedOverDate, + CreatedAt = now, + UpdatedAt = now, + }; + db.SaleReservations.Add(entity); + await db.SaveChangesAsync(); + return TypedResults.Created($"/reservations/{entity.Id}", ToDto(entity)); + }); + + group.MapPut("/{id:guid}", async Task, NotFound>> ( + Guid id, SaleReservationUpdate input, ApplicationContext db) => + { + var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id); + if (entity is null) + return TypedResults.NotFound(); + + if (input.Status is not null) + entity.Status = NormalizeStatus(input.Status); + if (input.GerbilName is not null) + entity.GerbilName = Trim(input.GerbilName); + if (input.ContactName is not null) + entity.ContactName = Trim(input.ContactName); + if (input.Note is not null) + entity.Note = Trim(input.Note); + + // Value-type/nullable fields are always applied from the payload (a null clears them). + entity.ReservedForContactId = input.ReservedForContactId; + entity.AppointmentDate = input.AppointmentDate; + entity.Price = input.Price; + entity.HandedOverDate = input.HandedOverDate; + + entity.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(); + return TypedResults.Ok(ToDto(entity)); + }); + + group.MapDelete("/{id:guid}", async Task> ( + Guid id, ApplicationContext db) => + { + var entity = await db.SaleReservations.FirstOrDefaultAsync(r => r.Id == id); + if (entity is null) + return TypedResults.NotFound(); + + db.SaleReservations.Remove(entity); + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + private static string? Trim(string? s) => + string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + private static SaleReservationDto ToDto(SaleReservation r) => + new(r.Id, r.GerbilId, r.GerbilName, r.Status, r.ReservedForContactId, r.ContactName, + r.AppointmentDate, r.Price, r.Note, r.HandedOverDate, r.CreatedAt, r.UpdatedAt); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs new file mode 100644 index 0000000..cc86551 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.Designer.cs @@ -0,0 +1,1596 @@ +// +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("20260622201531_AddSaleReservation")] + partial class AddSaleReservation + { + /// + 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.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.SaleReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppointmentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("HandedOverDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("ReservedForContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleReservations"); + }); + + 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/20260622201531_AddSaleReservation.cs b/GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.cs new file mode 100644 index 0000000..76ff1c8 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201531_AddSaleReservation.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddSaleReservation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SaleReservations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GerbilId = table.Column(type: "uuid", nullable: false), + GerbilName = table.Column(type: "text", nullable: true), + Status = table.Column(type: "text", nullable: false), + ReservedForContactId = table.Column(type: "uuid", nullable: true), + ContactName = table.Column(type: "text", nullable: true), + AppointmentDate = table.Column(type: "timestamp with time zone", nullable: true), + Price = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true), + Note = table.Column(type: "text", nullable: true), + HandedOverDate = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleReservations", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SaleReservations_GerbilId", + table: "SaleReservations", + column: "GerbilId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SaleReservations"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 725b04b..185c9a6 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -1335,6 +1335,54 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("SaleContractAnimal"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppointmentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("HandedOverDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("ReservedForContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleReservations"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => { b.Property("Id") diff --git a/GerbilManagerWebAPI/Models/SaleReservation.cs b/GerbilManagerWebAPI/Models/SaleReservation.cs new file mode 100644 index 0000000..3760ba3 --- /dev/null +++ b/GerbilManagerWebAPI/Models/SaleReservation.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// ABGABE-STATUS (RPRO3 abgeben_tb / abstat_tb): the pre-handover reservation/sale + /// pipeline for an animal — verfügbar (available) → reserviert (reserved) → abgegeben + /// (handed over). This is the *informal status* that precedes the legal sale contract + /// (SaleContract) — it does NOT replace it. + /// + /// Deliberately decoupled from the rest of the model (same pattern as Feedback): + /// GerbilId and ReservedForContactId are plain nullable Guid columns, NOT enforced + /// foreign keys, so the import re-ingest wipe (IngestResolvedService) can delete and + /// recreate gerbils/contacts without deleting or breaking reservation rows. The + /// captured GerbilName/ContactName keep the row human-readable after a wipe. + /// + public class SaleReservation + { + [Key] + public Guid Id { get; set; } + + /// Loose reference (no FK) to the animal this status is about. + public Guid GerbilId { get; set; } + + /// Captured animal name (survives an ingest wipe). + public string? GerbilName { get; set; } + + /// + /// Status: "verfuegbar" | "reserviert" | "abgegeben". Plain string, no FK — + /// keeps the row decoupled and ingest-surviving. + /// + public string Status { get; set; } = "verfuegbar"; + + /// Loose reference (no FK) to the reserving/receiving contact (Interessent/Abnehmer), if any. + public Guid? ReservedForContactId { get; set; } + + /// Captured contact name (survives an ingest wipe). + public string? ContactName { get; set; } + + /// Planned hand-over appointment (RPRO3 abgeben_tb._TERMIN). + public DateTime? AppointmentDate { get; set; } + + /// Agreed price (RPRO3 abstat_tb._PRICE). + public decimal? Price { get; set; } + + /// Free-text note (RPRO3 _BEM). + public string? Note { get; set; } + + /// When the animal was actually handed over (RPRO3 abstat_tb._AM); null until "abgegeben". + public DateTime? HandedOverDate { get; set; } + + /// Server-side creation time. + public DateTimeOffset CreatedAt { get; set; } + + /// Server-side last-update time. + public DateTimeOffset UpdatedAt { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index ecb8493..a6aec6f 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -127,6 +127,7 @@ app.MapCmsEndpoints(); app.MapRequestEndpoints(); app.MapNamesEndpoints(); app.MapFeedbackEndpoints(); +app.MapSaleReservationEndpoints(); app.Run(); diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 78faa7a..a80111e 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -416,6 +416,68 @@ export async function installMockApi(page: Page): Promise { return json(route, 405) } + // ABGABE-STATUS: /reservations — Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben). + if (path === '/reservations') { + const allowed = ['verfuegbar', 'reserviert', 'abgegeben'] + const normStatus = (s: unknown) => + typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar' + if (method === 'POST') { + const body = request.postDataJSON() as Row + if (!body.gerbilId) return json(route, 400, 'GerbilId ist erforderlich.') + const nowIso = new Date().toISOString() + const created = { + id: newId('reservation'), + gerbilId: body.gerbilId, + gerbilName: body.gerbilName ?? null, + status: normStatus(body.status), + reservedForContactId: body.reservedForContactId ?? null, + contactName: body.contactName ?? null, + appointmentDate: body.appointmentDate ?? null, + price: body.price ?? null, + note: body.note ?? null, + handedOverDate: body.handedOverDate ?? null, + createdAt: nowIso, + updatedAt: nowIso, + } + db.reservations.push(created) + return json(route, 201, created) + } + if (method === 'GET') { + const gid = url.searchParams.get('gerbilId') + const rows = gid ? db.reservations.filter((r) => r.gerbilId === gid) : [...db.reservations] + return json(route, 200, rows.slice().reverse()) + } + return json(route, 405) + } + const resm = path.match(/^\/reservations\/([^/]+)$/) + if (resm) { + const allowed = ['verfuegbar', 'reserviert', 'abgegeben'] + const normStatus = (s: unknown) => + typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar' + const rid = decodeURIComponent(resm[1]) + const idx = db.reservations.findIndex((r) => r.id === rid) + if (idx < 0) return json(route, 404, { title: 'Not Found' }) + if (method === 'PUT') { + const body = request.postDataJSON() as Row + const row = db.reservations[idx] + if (typeof body.status === 'string') row.status = normStatus(body.status) + if ('gerbilName' in body) row.gerbilName = body.gerbilName ?? null + if ('contactName' in body) row.contactName = body.contactName ?? null + if ('note' in body) row.note = body.note ?? null + if ('reservedForContactId' in body) row.reservedForContactId = body.reservedForContactId ?? null + if ('appointmentDate' in body) row.appointmentDate = body.appointmentDate ?? null + if ('price' in body) row.price = body.price ?? null + if ('handedOverDate' in body) row.handedOverDate = body.handedOverDate ?? null + row.updatedAt = new Date().toISOString() + return json(route, 200, row) + } + if (method === 'DELETE') { + db.reservations.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..10292cc 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[] + // ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations) + reservations: Record[] } function gerbil( @@ -376,5 +378,50 @@ export function seedDb(): MockDb { saleAdConfigured: true, namesConfigured: true, feedback: [], + // ABGABE-STATUS: eine reservierte + eine abgegebene + eine verfügbare Vormerkung. + reservations: [ + { + id: 'res-reserviert', + gerbilId: 'sale-balu', + gerbilName: 'Balu Abgabe', + status: 'reserviert', + reservedForContactId: 'con-huber', + contactName: 'Familie Huber', + appointmentDate: '2026-07-01T00:00:00Z', + price: 25, + note: 'Käfig wird mitgebracht.', + handedOverDate: null, + createdAt: '2026-06-15T09:00:00Z', + updatedAt: '2026-06-15T09:00:00Z', + }, + { + id: 'res-abgegeben', + gerbilId: 'pup-abgabe', + gerbilName: 'Pippa', + status: 'abgegeben', + reservedForContactId: 'con-huber', + contactName: 'Familie Huber', + appointmentDate: null, + price: 20, + note: null, + handedOverDate: '2025-05-01T00:00:00Z', + createdAt: '2025-04-20T09:00:00Z', + updatedAt: '2025-05-01T09:00:00Z', + }, + { + id: 'res-verfuegbar', + gerbilId: 'sale-benny', + gerbilName: 'Benny Abgabe', + status: 'verfuegbar', + reservedForContactId: null, + contactName: null, + appointmentDate: null, + price: null, + note: null, + handedOverDate: null, + createdAt: '2026-06-16T09:00:00Z', + updatedAt: '2026-06-16T09:00:00Z', + }, + ], } } diff --git a/gerbil-manager-web/e2e/reservierungen.spec.ts b/gerbil-manager-web/e2e/reservierungen.spec.ts new file mode 100644 index 0000000..e3e9cb1 --- /dev/null +++ b/gerbil-manager-web/e2e/reservierungen.spec.ts @@ -0,0 +1,78 @@ +/** + * ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite (verfügbar → reserviert → abgegeben). + * Erreichbar über das Menü („Reservierungen"). Liste mit Status-Chips, Tier-/Kontakt-Links, + * Anlegen, Status-Wechsel und Löschen. + */ +import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures' + +const tr = de.pages.reservierungen + +test.describe('Reservierungen', () => { + test.beforeEach(() => skipUnlessMock()) + + test('über das Menü erreichbar, zeigt die Seed-Reservierungen', async ({ page }) => { + await gotoSection(page, de.nav.reservations) + await expect(page.getByRole('heading', { name: tr.title })).toBeVisible() + + // Reservierte + abgegebene + verfügbare Vormerkung sichtbar. + await expect(page.getByText('Balu Abgabe')).toBeVisible() + await expect(page.getByText('Pippa')).toBeVisible() + await expect(page.getByText('Benny Abgabe')).toBeVisible() + }) + + test('Status-Filter filtern korrekt (Anzahl-Badges stimmen)', async ({ page }) => { + await page.goto('/reservierungen') + + const filters = page.locator('.reservierungen-filter') + await filters.filter({ hasText: tr.filters.reserviert }).click() + await expect(page.getByText('Balu Abgabe')).toBeVisible() + await expect(page.getByText('Benny Abgabe')).toHaveCount(0) + + await filters.filter({ hasText: tr.filters.abgegeben }).click() + await expect(page.getByText('Pippa')).toBeVisible() + await expect(page.getByText('Balu Abgabe')).toHaveCount(0) + }) + + test('Tier- und Kontakt-Bezüge sind verlinkt', async ({ page }) => { + await page.goto('/reservierungen') + const card = page.locator('.reservation-card').filter({ hasText: 'Balu Abgabe' }) + await expect(card.getByRole('link', { name: 'Balu Abgabe' })).toHaveAttribute('href', /\/rennmaeuse\/sale-balu/) + await expect(card.getByRole('link', { name: 'Familie Huber' })).toHaveAttribute('href', /\/kontakte\/con-huber/) + }) + + test('Status wechseln: verfügbares Tier reservieren', async ({ page }) => { + await page.goto('/reservierungen') + const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' }) + await expect(card.locator('.reservation-badge--verfuegbar')).toBeVisible() + + await card.getByRole('button', { name: tr.actions.markReserved }).click() + await expect( + page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' }).locator('.reservation-badge--reserviert'), + ).toBeVisible() + }) + + test('neue Reservierung anlegen', async ({ page }) => { + await page.goto('/reservierungen') + await page.getByRole('button', { name: tr.newButton }).click() + + const form = page.locator('.reservation-form') + await expect(form).toBeVisible() + // Ein Tier auswählen (Krümel ist in den Seed-Daten vorhanden). + await form.getByLabel(tr.form.animal).selectOption({ label: 'Krümel' }) + await form.getByRole('button', { name: tr.form.save }).click() + + // Die neue Reservierung erscheint in der Liste. + await expect(page.locator('.reservation-card').filter({ hasText: 'Krümel' })).toBeVisible() + }) + + test('Reservierung löschen', async ({ page }) => { + await page.goto('/reservierungen') + const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' }) + await expect(card).toBeVisible() + + acceptNextDialog(page) + await card.getByRole('button', { name: tr.actions.delete }).click() + + await expect(page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })).toHaveCount(0) + }) +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 6c1af9a..f77453c 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -8,6 +8,7 @@ import GerbilDetailPage from './pages/GerbilDetailPage' import GerbilFormPage from './pages/GerbilFormPage' import GenetikPage from './pages/GenetikPage' import AbgabePage from './pages/AbgabePage' +import ReservierungenPage from './pages/ReservierungenPage' import KontaktePage from './pages/KontaktePage' import KontaktDetailPage from './pages/KontaktDetailPage' import KontaktFormPage from './pages/KontaktFormPage' @@ -69,6 +70,8 @@ export default function App() { } /> } /> + {/* ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) */} + } /> } /> } /> } /> diff --git a/gerbil-manager-web/src/api/reservations.ts b/gerbil-manager-web/src/api/reservations.ts new file mode 100644 index 0000000..e9e6a7f --- /dev/null +++ b/gerbil-manager-web/src/api/reservations.ts @@ -0,0 +1,66 @@ +/** + * ABGABE-STATUS: API client for the reservation/sale-status pipeline (/reservations). + * Status verfügbar → reserviert → abgegeben. This is the informal pre-handover status — + * separate from the legal Vertrag (SaleContract). + */ +import { api } from './client' + +const RESOURCE = '/reservations' + +/** Canonical status values (match the backend contract, case-sensitive). */ +export type ReservationStatus = 'verfuegbar' | 'reserviert' | 'abgegeben' + +export interface SaleReservation { + id: string + gerbilId: string + gerbilName: string | null + status: ReservationStatus + reservedForContactId: string | null + contactName: string | null + /** ISO-8601 appointment date or null. */ + appointmentDate: string | null + price: number | null + note: string | null + /** ISO-8601 hand-over date or null. */ + handedOverDate: string | null + createdAt: string + updatedAt: string +} + +/** Payload for POST /reservations. */ +export interface CreateReservation { + gerbilId: string + gerbilName?: string | null + status?: ReservationStatus + reservedForContactId?: string | null + contactName?: string | null + appointmentDate?: string | null + price?: number | null + note?: string | null + handedOverDate?: string | null +} + +/** Payload for PUT /reservations/{id}. */ +export type UpdateReservation = Partial> + +/** List all reservations, newest-updated first. */ +export function listReservations(): Promise { + return api.get(RESOURCE) +} + +/** List the reservation status rows for one animal. */ +export function listReservationsForGerbil(gerbilId: string): Promise { + return api.get(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`) +} + +export function createReservation(body: CreateReservation): Promise { + return api.post(RESOURCE, body) +} + +export function updateReservation(id: string, body: UpdateReservation): Promise { + return api.put(`${RESOURCE}/${id}`, body) +} + +export function deleteReservation(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..8d2073c 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -24,6 +24,8 @@ const SECONDARY: NavItem[] = [ { to: '/gehege', label: de.nav.enclosures, icon: '🏜️' }, { to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, + // ABGABE-STATUS: Reservierungs-/Abgabe-Status + { to: '/reservierungen', label: de.nav.reservations, icon: '🔖' }, // INBOX-1: Anfragen-Posteingang { to: '/anfragen', label: de.nav.requests, icon: '📨' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' }, diff --git a/gerbil-manager-web/src/pages/ReservierungenPage.tsx b/gerbil-manager-web/src/pages/ReservierungenPage.tsx new file mode 100644 index 0000000..c99d6a0 --- /dev/null +++ b/gerbil-manager-web/src/pages/ReservierungenPage.tsx @@ -0,0 +1,417 @@ +/** + * ABGABE-STATUS: Reservierungs-/Abgabe-Status-Verwaltung. + * Liste der (vor allem abzugebenden) Tiere mit Status verfügbar → reserviert → abgegeben, + * inkl. Reservierung für einen Kontakt, Termin, Preis und Notiz. Tier- und Kontakt-Bezüge + * sind als Links hinterlegt. Dieser Status ist die Vormerkung VOR dem rechtlichen Vertrag + * (Verträge-Seite) — er ersetzt ihn nicht. + */ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { de } from '../strings/de' +import { listGerbils } from '../api/gerbils' +import { listContactsPaged } from '../api/contacts' +import { + createReservation, + deleteReservation, + listReservations, + updateReservation, + type ReservationStatus, + type SaleReservation, +} from '../api/reservations' +import { useApi, useMutation } from '../hooks/useApi' +import type { Contact, Gerbil } from '../api/types' +import './reservierungen.css' + +const STATUSES: ReservationStatus[] = ['verfuegbar', 'reserviert', 'abgegeben'] +type FilterKey = 'all' | ReservationStatus + +/** ISO-Datetime → "TT.MM.JJJJ"; leer/ungültig → Strich. */ +function dateOnly(iso: string | null): string { + if (!iso) return de.pages.reservierungen.fields.none + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +/** ISO-Datetime → Wert für ("YYYY-MM-DD"). */ +function toDateInput(iso: string | null): string { + if (!iso) return '' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + return date.toISOString().slice(0, 10) +} + +/** -Wert ("YYYY-MM-DD") → ISO-Datetime oder null. */ +function fromDateInput(value: string): string | null { + if (!value) return null + return new Date(`${value}T00:00:00Z`).toISOString() +} + +interface FormState { + id: string | null + gerbilId: string + status: ReservationStatus + reservedForContactId: string + appointmentDate: string + price: string + note: string + handedOverDate: string +} + +const emptyForm: FormState = { + id: null, + gerbilId: '', + status: 'reserviert', + reservedForContactId: '', + appointmentDate: '', + price: '', + note: '', + handedOverDate: '', +} + +export default function ReservierungenPage() { + const t = de.pages.reservierungen + + const reservations = useApi(() => listReservations(), []) + const gerbils = useApi( + () => listGerbils({ orderBy: 'name', page: 1, pageSize: 1000 }), + [], + ) + const contacts = useApi( + () => listContactsPaged({ orderBy: 'name', page: 1, pageSize: 1000 }), + [], + ) + + const createM = useMutation(createReservation) + const updateM = useMutation(updateReservation) + const deleteM = useMutation(deleteReservation) + + const [filter, setFilter] = useState('all') + const [form, setForm] = useState(null) + const [formError, setFormError] = useState(null) + + const gerbilById = useMemo( + () => new Map((gerbils.data?.items ?? []).map((g: Gerbil) => [g.id, g])), + [gerbils.data], + ) + const contactById = useMemo( + () => new Map((contacts.data?.items ?? []).map((c: Contact) => [c.id, c])), + [contacts.data], + ) + + const rows = useMemo(() => { + const all = reservations.data ?? [] + return filter === 'all' ? all : all.filter((r) => r.status === filter) + }, [reservations.data, filter]) + + const counts = useMemo(() => { + const all = reservations.data ?? [] + const c: Record = { all: all.length, verfuegbar: 0, reserviert: 0, abgegeben: 0 } + for (const r of all) c[r.status] += 1 + return c + }, [reservations.data]) + + function gerbilName(r: SaleReservation): string { + return gerbilById.get(r.gerbilId)?.name ?? r.gerbilName ?? t.fields.none + } + function contactName(r: SaleReservation): string { + if (!r.reservedForContactId) return t.fields.none + return contactById.get(r.reservedForContactId)?.name ?? r.contactName ?? t.fields.none + } + + function openCreate() { + setFormError(null) + setForm({ ...emptyForm }) + } + function openEdit(r: SaleReservation) { + setFormError(null) + setForm({ + id: r.id, + gerbilId: r.gerbilId, + status: r.status, + reservedForContactId: r.reservedForContactId ?? '', + appointmentDate: toDateInput(r.appointmentDate), + price: r.price != null ? String(r.price) : '', + note: r.note ?? '', + handedOverDate: toDateInput(r.handedOverDate), + }) + } + + async function saveForm() { + if (!form) return + if (!form.gerbilId) { + setFormError(t.form.chooseAnimal) + return + } + const g = gerbilById.get(form.gerbilId) + const c = form.reservedForContactId ? contactById.get(form.reservedForContactId) : undefined + const priceNum = form.price.trim() === '' ? null : Number(form.price.replace(',', '.')) + const payload = { + status: form.status, + reservedForContactId: form.reservedForContactId || null, + contactName: c?.name ?? null, + appointmentDate: fromDateInput(form.appointmentDate), + price: priceNum != null && !Number.isNaN(priceNum) ? priceNum : null, + note: form.note.trim() || null, + handedOverDate: fromDateInput(form.handedOverDate), + } + const result = form.id + ? await updateM.run(form.id, payload) + : await createM.run({ gerbilId: form.gerbilId, gerbilName: g?.name ?? null, ...payload }) + if (result.ok) { + setForm(null) + reservations.reload() + } else { + setFormError(t.form.saveError) + } + } + + async function quickStatus(r: SaleReservation, status: ReservationStatus) { + const patch: Parameters[1] = { status } + if (status === 'abgegeben' && !r.handedOverDate) patch.handedOverDate = new Date().toISOString() + const result = await updateM.run(r.id, patch) + if (result.ok) reservations.reload() + } + + async function remove(r: SaleReservation) { + if (!window.confirm(t.actions.confirmDelete)) return + const result = await deleteM.run(r.id) + if (result.ok) reservations.reload() + } + + if (reservations.loading) return

{de.common.loading}

+ if (reservations.error) return

{t.loadError}

+ + return ( +
+

{t.title}

+

{t.subtitle}

+

{t.contractHint}

+ +
+
+ {(['all', ...STATUSES] as FilterKey[]).map((key) => ( + + ))} +
+ +
+ + {form && ( + setForm(null)} + /> + )} + + {rows.length === 0 ? ( +

{t.empty}

+ ) : ( +
    + {rows.map((r) => ( +
  • +
    + + {gerbilName(r)} + + + {t.status[r.status]} + +
    +
    +
    +
    {t.fields.reservedFor}
    +
    + {r.reservedForContactId ? ( + {contactName(r)} + ) : ( + contactName(r) + )} +
    +
    +
    +
    {t.fields.appointment}
    +
    {dateOnly(r.appointmentDate)}
    +
    +
    +
    {t.fields.price}
    +
    {r.price != null ? `${r.price.toFixed(2)} €` : t.fields.none}
    +
    +
    +
    {t.fields.handedOver}
    +
    {dateOnly(r.handedOverDate)}
    +
    +
    + {r.note &&

    {r.note}

    } +
    + {r.status !== 'reserviert' && ( + + )} + {r.status !== 'abgegeben' && ( + + )} + {r.status !== 'verfuegbar' && ( + + )} + + +
    +
  • + ))} +
+ )} +
+ ) +} + +interface FormProps { + t: typeof de.pages.reservierungen + form: FormState + setForm: (f: FormState) => void + gerbils: Gerbil[] + contacts: Contact[] + error: string | null + pending: boolean + onSave: () => void + onCancel: () => void +} + +function ReservationForm({ t, form, setForm, gerbils, contacts, error, pending, onSave, onCancel }: FormProps) { + const isEdit = form.id != null + return ( +
{ + e.preventDefault() + onSave() + }} + > +

{isEdit ? t.form.editTitle : t.form.addTitle}

+ + + + + + + + + + + + + +