From daa6683405130d2b7d916846417d190c788ccdb0 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:23:06 +0200 Subject: [PATCH 1/8] FEAT-13: SaleContract + BreederSettings entities (additive migration, join table justified in code docs), /contracts + /settings/breeder-profile Minimal-API endpoints w/ Abgabe-completion transaction + SQLite-backed endpoint round-trip tests (21/21) Co-Authored-By: Claude Opus 4.8 (1M context) --- GerbilManager.Tests/ApiFactory.cs | 61 + GerbilManager.Tests/ContractEndpointTests.cs | 198 ++++ .../GerbilManager.Tests.csproj | 2 + GerbilManagerWebAPI/ApplicationContext.cs | 27 + GerbilManagerWebAPI/Dtos/ContractDtos.cs | 33 + .../Endpoints/ContractEndpoints.cs | 201 ++++ .../Endpoints/SettingsEndpoints.cs | 60 + ...aleContractsAndBreederSettings.Designer.cs | 1024 +++++++++++++++++ ...6051954_SaleContractsAndBreederSettings.cs | 108 ++ .../ApplicationContextModelSnapshot.cs | 133 +++ GerbilManagerWebAPI/Models/BreederSettings.cs | 36 + GerbilManagerWebAPI/Models/SaleContract.cs | 45 + GerbilManagerWebAPI/Program.cs | 5 + 13 files changed, 1933 insertions(+) create mode 100644 GerbilManager.Tests/ApiFactory.cs create mode 100644 GerbilManager.Tests/ContractEndpointTests.cs create mode 100644 GerbilManagerWebAPI/Dtos/ContractDtos.cs create mode 100644 GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs create mode 100644 GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs create mode 100644 GerbilManagerWebAPI/Models/BreederSettings.cs create mode 100644 GerbilManagerWebAPI/Models/SaleContract.cs diff --git a/GerbilManager.Tests/ApiFactory.cs b/GerbilManager.Tests/ApiFactory.cs new file mode 100644 index 0000000..98c1167 --- /dev/null +++ b/GerbilManager.Tests/ApiFactory.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GerbilManager.Tests; + +/// +/// FEAT-13: In-Memory-Test-Host für Endpoint-Round-Trips. Ersetzt die +/// Aspire/Npgsql-Registrierung durch SQLite in-memory (eine offene Verbindung +/// hält die DB am Leben), Umgebung "Testing" überspringt Database.Migrate() +/// (Npgsql-Migrationen laufen nicht auf SQLite) — Schema via EnsureCreated, +/// inklusive der HasData-Seeds (73 Farbschläge + Zuchtprofil-Singleton). +/// Vertrags-Dateien landen in einem Temp-Ordner, der mit dem Host stirbt. +/// +public sealed class ApiFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + + public string ContractRoot { get; } = + Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + // Dummy, damit Aspires AddNpgsqlDbContext beim Host-Aufbau zufrieden ist; + // die eigentliche Registrierung wird unten durch SQLite ersetzt. + builder.UseSetting("ConnectionStrings:gerbilmanager", + "Host=localhost;Database=test;Username=test;Password=test"); + builder.UseSetting("Contracts:RootPath", ContractRoot); + + builder.ConfigureServices(services => + { + // EF 9+: AddDbContext registriert die Provider-Konfiguration als + // IDbContextOptionsConfiguration — ohne deren Entfernung blieben + // Npgsql UND SQLite registriert (ein Provider pro ServiceProvider). + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + + _connection.Open(); + services.AddDbContext(o => o.UseSqlite(_connection)); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + _connection.Dispose(); + if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true); + } + } +} diff --git a/GerbilManager.Tests/ContractEndpointTests.cs b/GerbilManager.Tests/ContractEndpointTests.cs new file mode 100644 index 0000000..b6ed59a --- /dev/null +++ b/GerbilManager.Tests/ContractEndpointTests.cs @@ -0,0 +1,198 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace GerbilManager.Tests; + +/// +/// FEAT-13 phase B: Endpoint-Round-Trips für /contracts + /settings/breeder-profile +/// gegen den In-Memory-Host (ApiFactory). Deckt den Abgabe-Abschluss ab: +/// Vertrag erzeugen -> Tiere stehen auf Abgegeben -> .docx ist herunterladbar. +/// +public class ContractEndpointTests : IClassFixture +{ + private static readonly JsonSerializerOptions Json = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var o = new JsonSerializerOptions(JsonSerializerDefaults.Web); + o.Converters.Add(new JsonStringEnumConverter()); + return o; + } + + private readonly ApiFactory _factory; + + public ContractEndpointTests(ApiFactory factory) => _factory = factory; + + private sealed record IdDto(Guid Id); + + private sealed record GerbilView(Guid Id, string Status, Guid? ReceiverContactId, DateOnly? GoHomeDate); + + private sealed record ContractView( + Guid Id, Guid ContactId, decimal Price, DateOnly HandoverDate, DateOnly ContractDate, + string FileName, List GerbilIds, string Url); + + private sealed record Paged(List Items, int TotalCount, int Page, int PageSize); + + private async Task CreateContactAsync(HttpClient client, string name = "Herr Max Beispiel") + { + var response = await client.PostAsJsonAsync("/contacts", new + { + name, + email = "max@example.com", + phone = "0987 654321", + address = "Beispielallee 7, 54321 Beispielstadt", + }, Json); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(Json))!.Id; + } + + private async Task CreateGerbilAsync(HttpClient client, string name) + { + var response = await client.PostAsJsonAsync("/gerbils", new + { + name, + gender = "female", + dateOfBirth = "2025-03-09", + }, Json); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(Json))!.Id; + } + + [Fact] + public async Task ZuchtprofilRoundTrip_PutDannGet() + { + var client = _factory.CreateClient(); + + var put = await client.PutAsJsonAsync("/settings/breeder-profile", new + { + zuchtName = "Zucht Testhausen", + name = "Frau Erika Muster", + address = "Musterweg 1, 12345 Testhausen", + phone = "0123 456789", + email = "zucht@example.org", + homepage = "https://zucht.example.org/", + city = "Testhausen", + }, Json); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + + var profile = await client.GetFromJsonAsync>("/settings/breeder-profile", Json); + Assert.Equal("Zucht Testhausen", profile!["zuchtName"]); + Assert.Equal("Testhausen", profile["city"]); + } + + [Fact] + public async Task VertragErzeugen_SchließtDieAbgabeAb() + { + var client = _factory.CreateClient(); + + // Zuchtprofil füllen, damit der Verkäufer-Block im Dokument landet. + await client.PutAsJsonAsync("/settings/breeder-profile", new + { + zuchtName = "Zucht Testhausen", + name = "Frau Erika Muster", + address = "Musterweg 1, 12345 Testhausen", + phone = "0123 456789", + email = "zucht@example.org", + homepage = "https://zucht.example.org/", + city = "Testhausen", + }, Json); + + var contactId = await CreateContactAsync(client); + var krümel = await CreateGerbilAsync(client, "Krümel"); + var luna = await CreateGerbilAsync(client, "Luna"); + + // POST /contracts — der Abschluss. + var post = await client.PostAsJsonAsync("/contracts", new + { + contactId, + gerbilIds = new[] { krümel, luna }, + price = 72.0m, + handoverDate = "2026-06-05", + }, Json); + Assert.Equal(HttpStatusCode.Created, post.StatusCode); + var contract = (await post.Content.ReadFromJsonAsync(Json))!; + + Assert.Equal(contactId, contract.ContactId); + Assert.Equal(2, contract.GerbilIds.Count); + Assert.Equal(new DateOnly(2026, 6, 5), contract.ContractDate); // Standard = Übergabedatum + Assert.Equal($"/contracts/{contract.Id}/file", contract.Url); + + // Tiere stehen jetzt auf Abgegeben — mit Abnehmer und Abgabedatum. + foreach (var id in new[] { krümel, luna }) + { + var gerbil = await client.GetFromJsonAsync($"/gerbils/{id}", Json); + Assert.Equal("GivenAway", gerbil!.Status); + Assert.Equal(contactId, gerbil.ReceiverContactId); + Assert.Equal(new DateOnly(2026, 6, 5), gerbil.GoHomeDate); + } + + // Download: echtes .docx mit beiden Tieren, Käufer, de-DE-Preis. + var file = await client.GetAsync(contract.Url); + Assert.Equal(HttpStatusCode.OK, file.StatusCode); + Assert.Equal( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file.Content.Headers.ContentType!.MediaType); + Assert.Contains("Abgabevertrag_2026-06-05_Herr-Max-Beispiel.docx", + file.Content.Headers.ContentDisposition!.FileNameStar ?? file.Content.Headers.ContentDisposition.FileName); + + var text = DocumentText(await file.Content.ReadAsByteArrayAsync()); + Assert.Contains("Krümel", text); + Assert.Contains("Luna", text); + Assert.Contains("Herr Max Beispiel", text); + Assert.Contains("Kaufpreis von 72,00 €", text); + Assert.Contains("Testhausen, den 05.06.2026", text); + Assert.DoesNotContain("{{", text); + + // Liste: nach Abnehmer filterbar (Gridify). + var list = await client.GetFromJsonAsync>( + $"/contracts?filter=contactId=={contactId}", Json); + Assert.Equal(1, list!.TotalCount); + Assert.Equal(contract.Id, list.Items[0].Id); + + // DELETE räumt Zeile und Datei ab; Tier-Status bleibt unangetastet. + var delete = await client.DeleteAsync($"/contracts/{contract.Id}"); + Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(contract.Url)).StatusCode); + var still = await client.GetFromJsonAsync($"/gerbils/{krümel}", Json); + Assert.Equal("GivenAway", still!.Status); + } + + [Fact] + public async Task VertragOhneTiereOderMitUnbekanntemTier_Validierungsfehler() + { + var client = _factory.CreateClient(); + var contactId = await CreateContactAsync(client, "Frau Lisa Test"); + + var empty = await client.PostAsJsonAsync("/contracts", new + { + contactId, + gerbilIds = Array.Empty(), + price = 10m, + handoverDate = "2026-06-05", + }, Json); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + + var unknown = await client.PostAsJsonAsync("/contracts", new + { + contactId, + gerbilIds = new[] { Guid.NewGuid() }, + price = 10m, + handoverDate = "2026-06-05", + }, Json); + Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); + } + + /// Sichtbarer Text aus word/document.xml (Tags entfernt). + private static string DocumentText(byte[] docx) + { + using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read); + var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml"); + using var reader = new StreamReader(entry.Open(), Encoding.UTF8); + return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", ""); + } +} diff --git a/GerbilManager.Tests/GerbilManager.Tests.csproj b/GerbilManager.Tests/GerbilManager.Tests.csproj index d59000b..4feafc8 100644 --- a/GerbilManager.Tests/GerbilManager.Tests.csproj +++ b/GerbilManager.Tests/GerbilManager.Tests.csproj @@ -9,6 +9,8 @@ + + diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index aae01c7..fad81b3 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -15,6 +15,8 @@ public class ApplicationContext : DbContext public DbSet GerbilPhotos => Set(); public DbSet HealthRecords => Set(); public DbSet WeightRecords => Set(); + public DbSet SaleContracts => Set(); + public DbSet BreederSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -59,6 +61,31 @@ public class ApplicationContext : DbContext e.HasOne().WithMany() .HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade)); + // FEAT-13: Abgabeverträge + Zuchtprofil. + modelBuilder.Entity(e => + { + e.Property(c => c.Price).HasPrecision(10, 2); + // Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand, + // kein versehentlich löschbarer Datensatz. + e.HasOne(c => c.Contact).WithMany() + .HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => new { a.SaleContractId, a.GerbilId }); + e.HasOne().WithMany(c => c.Animals) + .HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade); + // Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung — + // der Vertrag (und seine .docx als Beleg) bleibt bestehen. + e.HasOne(a => a.Gerbil).WithMany() + .HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade); + }); + + // Zuchtprofil: genau eine (leere) Zeile mit fixer Id. + modelBuilder.Entity() + .HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId }); + SeedColorVarieties(modelBuilder); } diff --git a/GerbilManagerWebAPI/Dtos/ContractDtos.cs b/GerbilManagerWebAPI/Dtos/ContractDtos.cs new file mode 100644 index 0000000..0d24787 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/ContractDtos.cs @@ -0,0 +1,33 @@ +namespace GerbilManagerWebAPI.Dtos +{ + // FEAT-13: Abgabeverträge + Zuchtprofil (eigene Datei, hält ApiDtos.cs konfliktfrei). + + public record SaleContractDto( + Guid Id, + Guid ContactId, + decimal Price, + DateOnly HandoverDate, + DateOnly ContractDate, + string FileName, + DateTimeOffset CreatedAt, + IReadOnlyList GerbilIds, + // Download-URL der .docx ("/contracts/{id}/file"). + string Url); + + public record SaleContractInput( + Guid ContactId, + List GerbilIds, + decimal Price, + DateOnly HandoverDate, + DateOnly? ContractDate); + + /// Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile. + public record BreederProfileDto( + string ZuchtName, + string Name, + string Address, + string Phone, + string Email, + string Homepage, + string City); +} diff --git a/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs new file mode 100644 index 0000000..8f7be57 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs @@ -0,0 +1,201 @@ +using GerbilManagerWebAPI.Common; +using GerbilManagerWebAPI.Contracts; +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-13: Abgabeverträge. + /// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im + /// Vertrags-Dateiroot, legt die Vertragszeile an und stellt + /// die Tiere in DERSELBEN Transaktion auf Abgegeben + /// (ReceiverContactId, GoHomeDate, Status). + /// GET /contracts -> Gridify-paged (z. B. filter=contactId==…) + /// GET /contracts/{id} -> Metadaten + /// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname) + /// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt) + /// + public static class ContractEndpoints + { + private const string DocxContentType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/contracts").WithTags("Contracts"); + + // GET /contracts (Gridify; GerbilIds via Include mitgeladen) + group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) => + TypedResults.Ok(await db.SaleContracts.AsNoTracking() + .Include(c => c.Animals) + .ToPagedResultAsync(query, ToDto))); + + // GET /contracts/{id} + group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) => + { + var c = await db.SaleContracts.AsNoTracking() + .Include(x => x.Animals) + .FirstOrDefaultAsync(x => x.Id == id); + return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c)); + }); + + // POST /contracts — der Abgabe-Abschluss. + group.MapPost("/", async Task, ValidationProblem>> ( + SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var errors = new Dictionary(); + + var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList(); + if (gerbilIds.Count == 0) + errors["gerbilIds"] = ["Mindestens ein Tier auswählen."]; + if (input.Price < 0) + errors["price"] = ["Der Kaufpreis darf nicht negativ sein."]; + + var contact = await db.Contacts.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == input.ContactId); + if (contact is null) + errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."]; + + var gerbils = await db.Gerbils + .Include(g => g.ColorVariety) + .Where(g => gerbilIds.Contains(g.Id)) + .ToListAsync(); + if (gerbils.Count != gerbilIds.Count) + errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."]; + + if (errors.Count > 0) return TypedResults.ValidationProblem(errors); + + var settings = await db.BreederSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId) + ?? new BreederSettings(); + + var contractDate = input.ContractDate ?? input.HandoverDate; + var data = new ContractData( + Seller: ToSeller(settings), + Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email), + Animals: gerbils + .Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name)) + .ToList(), + Price: input.Price, + HandoverDate: input.HandoverDate, + ContractDate: contractDate); + + var bytes = ContractGenerator.Generate(data); + + var fileName = $"{Guid.NewGuid():N}.docx"; + var root = ContractRoot(config, env); + Directory.CreateDirectory(root); + await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes); + + var entity = new SaleContract + { + Id = Guid.NewGuid(), + ContactId = contact.Id, + Price = input.Price, + HandoverDate = input.HandoverDate, + ContractDate = contractDate, + FileName = fileName, + CreatedAt = DateTimeOffset.UtcNow, + Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(), + }; + db.SaleContracts.Add(entity); + + // Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion. + foreach (var g in gerbils) + { + g.ReceiverContactId = contact.Id; + g.GoHomeDate = input.HandoverDate; + g.Status = GerbilStatus.GivenAway; + } + + try + { + await db.SaveChangesAsync(); + } + catch + { + // DB fehlgeschlagen -> verwaiste Datei wieder aufräumen. + var orphan = Path.Combine(root, fileName); + if (File.Exists(orphan)) File.Delete(orphan); + throw; + } + + return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity)); + }); + + // GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen. + group.MapGet("/{id:guid}/file", async Task> ( + Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var c = await db.SaleContracts.AsNoTracking() + .Include(x => x.Contact) + .FirstOrDefaultAsync(x => x.Id == id); + if (c is null) return TypedResults.NotFound(); + + var path = Path.Combine(ContractRoot(config, env), c.FileName); + if (!File.Exists(path)) return TypedResults.NotFound(); + + var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx"; + return TypedResults.PhysicalFile(path, DocxContentType, download); + }); + + // DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht + // (das wäre Magie — Status korrigiert man am Tier selbst). + group.MapDelete("/{id:guid}", async Task> ( + Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id); + if (c is null) return TypedResults.NotFound(); + + var path = Path.Combine(ContractRoot(config, env), c.FileName); + if (File.Exists(path)) File.Delete(path); + + db.SaleContracts.Remove(c); // Joins kaskadieren + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) => + config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage"); + + private static BreederProfile ToSeller(BreederSettings s) => new() + { + ZuchtName = s.ZuchtName, + Name = s.Name, + Address = s.Address, + Phone = s.Phone, + Email = s.Email, + Homepage = s.Homepage, + City = s.City, + }; + + /// Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument). + internal static string GeschlechtText(Gender gender) => gender switch + { + Gender.male => "Männlich", + Gender.female => "Weiblich", + _ => "Unbekannt", + }; + + /// Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-'). + private static string Sanitize(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return "Abnehmer"; + var cleaned = new string(name.Trim() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') + .ToArray()); + return cleaned.Trim('-'); + } + + internal static SaleContractDto ToDto(SaleContract c) => new( + c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt, + c.Animals.Select(a => a.GerbilId).ToList(), + $"/contracts/{c.Id}/file"); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs new file mode 100644 index 0000000..cf032a4 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs @@ -0,0 +1,60 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-13: Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeile, + /// per Migration leer geseedet, von der Züchterin unter /einstellungen gepflegt. + /// GET /settings/breeder-profile -> BreederProfileDto + /// PUT /settings/breeder-profile -> 204 + /// + public static class SettingsEndpoints + { + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/settings").WithTags("Settings"); + + group.MapGet("/breeder-profile", async Task> (ApplicationContext db) => + { + var s = await LoadAsync(db, track: false); + return TypedResults.Ok(ToDto(s)); + }); + + group.MapPut("/breeder-profile", async Task (BreederProfileDto input, ApplicationContext db) => + { + var s = await LoadAsync(db, track: true); + s.ZuchtName = input.ZuchtName ?? ""; + s.Name = input.Name ?? ""; + s.Address = input.Address ?? ""; + s.Phone = input.Phone ?? ""; + s.Email = input.Email ?? ""; + s.Homepage = input.Homepage ?? ""; + s.City = input.City ?? ""; + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + /// Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt. + private static async Task LoadAsync(ApplicationContext db, bool track) + { + var query = track ? db.BreederSettings : db.BreederSettings.AsNoTracking(); + var s = await query.FirstOrDefaultAsync(x => x.Id == BreederSettings.SingletonId); + if (s is null) + { + s = new BreederSettings { Id = BreederSettings.SingletonId }; + db.BreederSettings.Add(s); + await db.SaveChangesAsync(); + } + return s; + } + + internal static BreederProfileDto ToDto(BreederSettings s) => + new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs new file mode 100644 index 0000000..c7fd2d3 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs @@ -0,0 +1,1024 @@ +// +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("20260606051954_SaleContractsAndBreederSettings")] + partial class SaleContractsAndBreederSettings + { + /// + 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.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("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 = "", + 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"); + + 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 = "Pink Eyed White (PEW)", + 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 = "Schwarzschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Siam (Marder-Hell)", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schimmel (Orangeschimmel)", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP-Hell", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", + Name = "Blaufuchs CP", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs-Hell CP", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Siam (Marder-Hell) dd", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Marder dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti dd CP", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti-Hell dd CP", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti dd CP", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti-Hell dd CP", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel dd", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs CP", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs CP", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "Silberagouti CP", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs-Hell CP", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs,hell CP", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs CP", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000071"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel-Hell dd", + SortOrder = 70 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000072"), + CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel CP", + SortOrder = 71 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000073"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Blaufuchs dd", + SortOrder = 72 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .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.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .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("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + 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("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + 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.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + 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.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.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.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs new file mode 100644 index 0000000..2467b32 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class SaleContractsAndBreederSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BreederSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ZuchtName = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Address = table.Column(type: "text", nullable: false), + Phone = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false), + Homepage = table.Column(type: "text", nullable: false), + City = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BreederSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SaleContracts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContactId = table.Column(type: "uuid", nullable: false), + Price = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false), + HandoverDate = table.Column(type: "date", nullable: false), + ContractDate = table.Column(type: "date", nullable: false), + FileName = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleContracts", x => x.Id); + table.ForeignKey( + name: "FK_SaleContracts_Contacts_ContactId", + column: x => x.ContactId, + principalTable: "Contacts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "SaleContractAnimal", + columns: table => new + { + SaleContractId = table.Column(type: "uuid", nullable: false), + GerbilId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleContractAnimal", x => new { x.SaleContractId, x.GerbilId }); + table.ForeignKey( + name: "FK_SaleContractAnimal_Gerbils_GerbilId", + column: x => x.GerbilId, + principalTable: "Gerbils", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SaleContractAnimal_SaleContracts_SaleContractId", + column: x => x.SaleContractId, + principalTable: "SaleContracts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "BreederSettings", + columns: new[] { "Id", "Address", "City", "Email", "Homepage", "Name", "Phone", "ZuchtName" }, + values: new object[] { new Guid("11111111-1111-1111-1111-000000000001"), "", "", "", "", "", "", "" }); + + migrationBuilder.CreateIndex( + name: "IX_SaleContractAnimal_GerbilId", + table: "SaleContractAnimal", + column: "GerbilId"); + + migrationBuilder.CreateIndex( + name: "IX_SaleContracts_ContactId", + table: "SaleContracts", + column: "ContactId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BreederSettings"); + + migrationBuilder.DropTable( + name: "SaleContractAnimal"); + + migrationBuilder.DropTable( + name: "SaleContracts"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 2116917..4a23362 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -21,6 +21,58 @@ namespace GerbilManagerWebAPI.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + 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("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 = "", + Phone = "", + ZuchtName = "" + }); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => { b.Property("Id") @@ -771,6 +823,54 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("Litters"); }); + 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.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => { b.Property("Id") @@ -869,6 +969,34 @@ namespace GerbilManagerWebAPI.Migrations b.Navigation("Mother"); }); + 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) @@ -882,6 +1010,11 @@ namespace GerbilManagerWebAPI.Migrations { b.Navigation("Gerbils"); }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); #pragma warning restore 612, 618 } } diff --git a/GerbilManagerWebAPI/Models/BreederSettings.cs b/GerbilManagerWebAPI/Models/BreederSettings.cs new file mode 100644 index 0000000..ab56ac9 --- /dev/null +++ b/GerbilManagerWebAPI/Models/BreederSettings.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeilen-Entity + /// (Singleton, fixe Id, leer geseedet). Die Züchterin pflegt ihre Daten in + /// der App unter /einstellungen statt in einer JSON-Datei; FEAT-13 phase B + /// ersetzt damit die appsettings-Variante aus phase A. + /// + public class BreederSettings + { + /// Fixe Id der einzigen Zeile (per HasData geseedet). + public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001"); + + [Key] + public Guid Id { get; set; } + + public string ZuchtName { get; set; } = ""; + + /// Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“. + public string Name { get; set; } = ""; + + /// Anschrift einzeilig: „Straße Nr, PLZ Ort“. + public string Address { get; set; } = ""; + + public string Phone { get; set; } = ""; + + public string Email { get; set; } = ""; + + public string Homepage { get; set; } = ""; + + /// Ort für die Unterschriftszeile („{Ort}, den {Datum}“). + public string City { get; set; } = ""; + } +} diff --git a/GerbilManagerWebAPI/Models/SaleContract.cs b/GerbilManagerWebAPI/Models/SaleContract.cs new file mode 100644 index 0000000..497179c --- /dev/null +++ b/GerbilManagerWebAPI/Models/SaleContract.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// Ein erzeugter Abgabevertrag (FEAT-13). Die .docx liegt wie die Fotos im + /// Datei-Root (Contracts:RootPath bzw. contract-storage/), in der DB steht + /// nur der Dateiname. Tiere hängen über + /// (Join-Entity statt uuid[]-Spalte: referenzielle Integrität, „Verträge + /// eines Tieres“ bleibt abfragbar, und pro Tier-Zeile ist später Platz für + /// Zusatzdaten wie den Einzelpreis). + /// + public class SaleContract + { + [Key] + public Guid Id { get; set; } + + /// Abnehmer (Käufer-Block des Vertrags). + public Guid ContactId { get; set; } + public Contact? Contact { get; set; } + + /// Kaufpreis in Euro (gesamt). + public decimal Price { get; set; } + + public DateOnly HandoverDate { get; set; } + + /// Datum der Unterschriftszeile (Standard: Übergabedatum). + public DateOnly ContractDate { get; set; } + + /// Dateiname der erzeugten .docx im Vertrags-Dateiroot. + public required string FileName { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public List Animals { get; set; } = []; + } + + /// Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel). + public class SaleContractAnimal + { + public Guid SaleContractId { get; set; } + public Guid GerbilId { get; set; } + public Gerbil? Gerbil { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index c4bfc3e..897d765 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -62,5 +62,10 @@ app.MapHealthRecordEndpoints(); app.MapWeightRecordEndpoints(); app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); +app.MapContractEndpoints(); +app.MapSettingsEndpoints(); app.Run(); + +// Sichtbarer Programmtyp für WebApplicationFactory (Endpoint-Tests). +public partial class Program { } From 3ec4f660b021d2c8ddadf77dc17665b850b82192 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:31:04 +0200 Subject: [PATCH 2/8] FEAT-13: Abgabe wizard (/vertraege/neu, 4 steps, ?tiere= prefill), Vertraege list w/ download+delete, /einstellungen Zuchtprofil form, contracts/settings API clients, nav entries, detail-page entry point + replace orphaned contactInfo with email/phone/address across Kontakt pages Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/src/App.tsx | 9 + gerbil-manager-web/src/api/client.ts | 1 + gerbil-manager-web/src/api/contacts.ts | 4 +- gerbil-manager-web/src/api/contracts.ts | 42 ++ gerbil-manager-web/src/api/settings.ts | 36 ++ gerbil-manager-web/src/api/types.ts | 4 +- .../src/components/AppShell.tsx | 3 + .../src/pages/EinstellungenPage.tsx | 100 ++++ .../src/pages/GerbilDetailPage.tsx | 6 + .../src/pages/KontaktDetailPage.tsx | 12 +- .../src/pages/KontaktFormPage.tsx | 45 +- gerbil-manager-web/src/pages/KontaktePage.tsx | 4 +- .../src/pages/VertraegeListPage.tsx | 123 +++++ .../src/pages/VertragWizardPage.tsx | 450 ++++++++++++++++++ .../src/pages/vertragWizard.css | 142 ++++++ gerbil-manager-web/src/strings/de.ts | 95 +++- 16 files changed, 1060 insertions(+), 16 deletions(-) create mode 100644 gerbil-manager-web/src/api/contracts.ts create mode 100644 gerbil-manager-web/src/api/settings.ts create mode 100644 gerbil-manager-web/src/pages/EinstellungenPage.tsx create mode 100644 gerbil-manager-web/src/pages/VertraegeListPage.tsx create mode 100644 gerbil-manager-web/src/pages/VertragWizardPage.tsx create mode 100644 gerbil-manager-web/src/pages/vertragWizard.css diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 99b054f..d37967d 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -18,6 +18,9 @@ import WurfFormPage from './pages/WurfFormPage' import NotFoundPage from './pages/NotFoundPage' import StammbaumPage from './pages/StammbaumPage' import StatistikPage from './pages/StatistikPage' +import VertraegeListPage from './pages/VertraegeListPage' +import VertragWizardPage from './pages/VertragWizardPage' +import EinstellungenPage from './pages/EinstellungenPage' export default function App() { return ( @@ -54,6 +57,12 @@ export default function App() { } /> } /> } /> + {/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */} + + } /> + } /> + + } /> } /> diff --git a/gerbil-manager-web/src/api/client.ts b/gerbil-manager-web/src/api/client.ts index 8f6eab6..d72af1f 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -68,4 +68,5 @@ export const resources = { contacts: '/contacts', enclosures: '/enclosures', colorVarieties: '/color-varieties', + contracts: '/contracts', } as const diff --git a/gerbil-manager-web/src/api/contacts.ts b/gerbil-manager-web/src/api/contacts.ts index cbfa0be..72f4c9f 100644 --- a/gerbil-manager-web/src/api/contacts.ts +++ b/gerbil-manager-web/src/api/contacts.ts @@ -6,7 +6,9 @@ import type { Contact, Paged } from './types' /** Payload for POST /contacts. */ export interface CreateContact { name: string - contactInfo?: string | null + email?: string | null + phone?: string | null + address?: string | null notes?: string | null } diff --git a/gerbil-manager-web/src/api/contracts.ts b/gerbil-manager-web/src/api/contracts.ts new file mode 100644 index 0000000..e5d7295 --- /dev/null +++ b/gerbil-manager-web/src/api/contracts.ts @@ -0,0 +1,42 @@ +/** FEAT-13: Abgabeverträge (/contracts). */ +import { API_BASE_URL, api, resources } from './client' +import { toQueryString, type GridifyQuery } from './gridify' +import type { DateOnlyString, Paged } from './types' + +export interface SaleContract { + id: string + contactId: string + price: number + handoverDate: DateOnlyString + contractDate: DateOnlyString + fileName: string + createdAt: string + gerbilIds: string[] + /** API-relativer Download-Pfad ("/contracts/{id}/file"). */ + url: string +} + +export interface CreateSaleContract { + contactId: string + gerbilIds: string[] + price: number + handoverDate: DateOnlyString + contractDate?: DateOnlyString | null +} + +export function listContracts(query: GridifyQuery): Promise> { + return api.get>(`${resources.contracts}${toQueryString(query)}`) +} + +export function createContract(body: CreateSaleContract): Promise { + return api.post(resources.contracts, body) +} + +export function deleteContract(id: string): Promise { + return api.delete(`${resources.contracts}/${id}`) +} + +/** Absolute Download-URL der .docx (für / window.open). */ +export function contractFileUrl(contract: Pick): string { + return `${API_BASE_URL}${contract.url}` +} diff --git a/gerbil-manager-web/src/api/settings.ts b/gerbil-manager-web/src/api/settings.ts new file mode 100644 index 0000000..248c550 --- /dev/null +++ b/gerbil-manager-web/src/api/settings.ts @@ -0,0 +1,36 @@ +/** FEAT-13: Zuchtprofil (/settings/breeder-profile) — Verkäufer-Block der Verträge. */ +import { api } from './client' + +export interface BreederProfile { + zuchtName: string + name: string + address: string + phone: string + email: string + homepage: string + /** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */ + city: string +} + +export const EMPTY_BREEDER_PROFILE: BreederProfile = { + zuchtName: '', + name: '', + address: '', + phone: '', + email: '', + homepage: '', + city: '', +} + +/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */ +export function isBreederProfileComplete(p: BreederProfile): boolean { + return [p.name, p.address, p.city].every((v) => v.trim().length > 0) +} + +export function getBreederProfile(): Promise { + return api.get('/settings/breeder-profile') +} + +export function putBreederProfile(profile: BreederProfile): Promise { + return api.put('/settings/breeder-profile', profile) +} diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts index e9f62a4..a59aa3e 100644 --- a/gerbil-manager-web/src/api/types.ts +++ b/gerbil-manager-web/src/api/types.ts @@ -73,7 +73,9 @@ export interface Enclosure { export interface Contact { id: string name: string - contactInfo: string | null + email: string | null + phone: string | null + address: string | null notes: string | null } diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index dc45d86..58bf183 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -23,6 +23,9 @@ const SECONDARY: NavItem[] = [ { to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' }, + // FEAT-13: Abgabeverträge + Zuchtprofil + { to: '/vertraege', label: de.nav.contracts, icon: '📄' }, + { to: '/einstellungen', label: de.nav.settings, icon: '⚙️' }, ] const linkClass = ({ isActive }: { isActive: boolean }) => diff --git a/gerbil-manager-web/src/pages/EinstellungenPage.tsx b/gerbil-manager-web/src/pages/EinstellungenPage.tsx new file mode 100644 index 0000000..a340778 --- /dev/null +++ b/gerbil-manager-web/src/pages/EinstellungenPage.tsx @@ -0,0 +1,100 @@ +/** + * FEAT-13: Einstellungen — Zuchtprofil (Verkäufer-Block der Abgabeverträge). + * Einzelzeilen-Settings im Backend (/settings/breeder-profile); die Züchterin + * pflegt ihre Daten hier statt in einer JSON-Datei. + */ +import { useState, type FormEvent } from 'react' +import { de } from '../strings/de' +import { + EMPTY_BREEDER_PROFILE, + getBreederProfile, + isBreederProfileComplete, + putBreederProfile, + type BreederProfile, +} from '../api/settings' +import { useApi, useMutation } from '../hooks/useApi' + +export default function EinstellungenPage() { + const t = de.pages.einstellungen + const tz = t.zuchtprofil + + const [form, setForm] = useState(EMPTY_BREEDER_PROFILE) + const [initialized, setInitialized] = useState(false) + const [saved, setSaved] = useState(false) + + const existing = useApi(() => getBreederProfile(), []) + if (existing.data && !initialized) { + setInitialized(true) + setForm(existing.data) + } + + const set = (key: K, value: string) => { + setSaved(false) + setForm((f) => ({ ...f, [key]: value })) + } + + const mutation = useMutation((profile: BreederProfile) => putBreederProfile(profile)) + + async function onSubmit(e: FormEvent) { + e.preventDefault() + const result = await mutation.run(form) + if (result.ok) setSaved(true) + } + + if (existing.loading) return

{de.common.loading}

+ if (existing.error) { + return ( +
+

{t.title}

+
+ {existing.error} + +
+
+ ) + } + + const field = ( + key: keyof BreederProfile, + label: string, + hint?: string, + type: string = 'text', + ) => ( + + ) + + return ( +
+

{t.title}

+ +

{tz.title}

+

{tz.intro}

+ {!isBreederProfileComplete(form) &&
{tz.incompleteHint}
} + +
+ {field('zuchtName', tz.fields.zuchtName)} + {field('name', tz.fields.name, tz.fields.nameHint)} + {field('address', tz.fields.address, tz.fields.addressHint)} + {field('city', tz.fields.city)} + {field('phone', tz.fields.phone, undefined, 'tel')} + {field('email', tz.fields.email, undefined, 'email')} + {field('homepage', tz.fields.homepage, undefined, 'url')} + + {mutation.error &&
{mutation.error}
} + +
+ + {saved && {tz.saved}} +
+
+
+ ) +} diff --git a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx index fbeae2b..4a52b27 100644 --- a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx @@ -113,6 +113,12 @@ export default function GerbilDetailPage() { {de.pages.stammbaum.openButton} + {/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */} + {g.status !== 'Deceased' && g.status !== 'GivenAway' && ( + + {de.pages.vertraege.wizard.title} + + )} {t.detail.back} diff --git a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx index d0b3103..fdc5318 100644 --- a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx +++ b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx @@ -97,8 +97,16 @@ export default function KontaktDetailPage() {
-
{t.fields.contactInfo}
-
{c.contactInfo ?? '—'}
+
{t.fields.email}
+
{c.email ?? '—'}
+
+
+
{t.fields.phone}
+
{c.phone ?? '—'}
+
+
+
{t.fields.address}
+
{c.address ?? '—'}
{t.fields.notes}
diff --git a/gerbil-manager-web/src/pages/KontaktFormPage.tsx b/gerbil-manager-web/src/pages/KontaktFormPage.tsx index 3aebe83..0480aa2 100644 --- a/gerbil-manager-web/src/pages/KontaktFormPage.tsx +++ b/gerbil-manager-web/src/pages/KontaktFormPage.tsx @@ -1,4 +1,5 @@ -/** FEAT-2: Kontakt anlegen/bearbeiten — Name (Pflicht), Kontaktdaten, Notizen. */ +/** FEAT-2: Kontakt anlegen/bearbeiten — Name (Pflicht), E-Mail/Telefon/Adresse, Notizen. + * (FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten — Käufer-Block der Verträge.) */ import { useState, type FormEvent } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { de } from '../strings/de' @@ -7,11 +8,13 @@ import { useApi, useMutation } from '../hooks/useApi' interface FormState { name: string - contactInfo: string + email: string + phone: string + address: string notes: string } -const EMPTY: FormState = { name: '', contactInfo: '', notes: '' } +const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' } /** "" -> null, sonst der Wert. */ const nn = (s: string): string | null => (s.trim() === '' ? null : s) @@ -33,7 +36,9 @@ export default function KontaktFormPage() { setInitializedFor(existing.data.id) setForm({ name: existing.data.name, - contactInfo: existing.data.contactInfo ?? '', + email: existing.data.email ?? '', + phone: existing.data.phone ?? '', + address: existing.data.address ?? '', notes: existing.data.notes ?? '', }) } @@ -54,7 +59,9 @@ export default function KontaktFormPage() { setErrors({}) const result = await mutation.run({ name: form.name.trim(), - contactInfo: nn(form.contactInfo), + email: nn(form.email), + phone: nn(form.phone), + address: nn(form.address), notes: nn(form.notes), }) if (result.ok) navigate(`/kontakte/${result.value.id}`) @@ -80,13 +87,33 @@ export default function KontaktFormPage() { + + + +
+
+
+

{t.title}

+

+ {totalCount} {t.countLabel} +

+
+
+ + {t.newButton} + +
+
+ + {removal.error &&
{removal.error}
} + + {items.length === 0 ? ( +

{t.empty}

+ ) : ( +
+ )} + + {totalPages > 1 && ( + + )} +
+ ) +} diff --git a/gerbil-manager-web/src/pages/VertragWizardPage.tsx b/gerbil-manager-web/src/pages/VertragWizardPage.tsx new file mode 100644 index 0000000..1434ffb --- /dev/null +++ b/gerbil-manager-web/src/pages/VertragWizardPage.tsx @@ -0,0 +1,450 @@ +/** + * FEAT-13: „Abgabe abschließen“ — Assistent (/vertraege/neu). + * + * Schritte: Abnehmer wählen/anlegen → Tiere bestätigen → Preis & Datum → + * Zusammenfassung + Vertrag erzeugen. Einstiege: Tier-Detail + * (?tiere=) und Kevins Abgabe-Gruppen (?tiere=); die + * Vorauswahl kommt aus dem Query-Parameter. POST /contracts erledigt + * serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag + * anlegen, Tiere auf „Abgegeben“ stellen (Abnehmer + Abgabedatum). + */ +import { useMemo, useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' +import { de } from '../strings/de' +import { listGerbils } from '../api/gerbils' +import { createContact, listContactsPaged } from '../api/contacts' +import { contractFileUrl, createContract, type SaleContract } from '../api/contracts' +import { getBreederProfile, isBreederProfileComplete } from '../api/settings' +import { listColorVarieties } from '../api/lookups' +import { useApi, useMutation } from '../hooks/useApi' +import { formatDate, genderLabel } from '../format/labels' +import './vertragWizard.css' + +type Step = 0 | 1 | 2 | 3 + +/** "72,00" / "72.5" / "72" → Zahl; null bei Unfug. */ +function parsePrice(input: string): number | null { + const normalized = input.trim().replace(/\./g, '').replace(',', '.') + if (normalized === '' || !/^\d+(\.\d{1,2})?$/.test(normalized)) return null + return Number(normalized) +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10) +} + +export default function VertragWizardPage() { + const t = de.pages.vertraege.wizard + const navigate = useNavigate() + const [params] = useSearchParams() + + const [step, setStep] = useState(0) + const [stepError, setStepError] = useState(null) + + /* ── Schritt 1: Abnehmer ── */ + const [contactId, setContactId] = useState(null) + const [contactSearch, setContactSearch] = useState('') + const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' }) + + const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), []) + const contactItems = useMemo(() => contacts.data?.items ?? [], [contacts.data]) + const filteredContacts = useMemo(() => { + const needle = contactSearch.trim().toLowerCase() + return needle === '' + ? contactItems + : contactItems.filter((c) => c.name.toLowerCase().includes(needle)) + }, [contactItems, contactSearch]) + const selectedContact = contactItems.find((c) => c.id === contactId) ?? null + + const contactCreation = useMutation(() => + createContact({ + name: newContact.name.trim(), + email: newContact.email.trim() || null, + phone: newContact.phone.trim() || null, + address: newContact.address.trim() || null, + }), + ) + async function onCreateContact() { + if (newContact.name.trim() === '') { + setStepError(t.contactNameRequired) + return + } + setStepError(null) + const result = await contactCreation.run() + if (result.ok) { + contacts.reload() + setContactId(result.value.id) + setNewContact({ name: '', email: '', phone: '', address: '' }) + } + } + + /* ── Schritt 2: Tiere (lebend, nicht abgegeben; Vorauswahl aus ?tiere=) ── */ + const [selectedIds, setSelectedIds] = useState>( + () => new Set((params.get('tiere') ?? '').split(',').filter(Boolean)), + ) + const animals = useApi( + () => + listGerbils({ + page: 1, + pageSize: 1000, + orderBy: 'name', + filter: 'status!=Deceased,status!=GivenAway', + }), + [], + ) + const colorVarieties = useApi(() => listColorVarieties(), []) + const colorName = useMemo( + () => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])), + [colorVarieties.data], + ) + const animalItems = animals.data?.items ?? [] + const selectedAnimals = animalItems.filter((g) => selectedIds.has(g.id)) + + const toggleAnimal = (id: string) => + setSelectedIds((ids) => { + const next = new Set(ids) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + + /* ── Schritt 3: Preis & Datum ── */ + const [priceText, setPriceText] = useState('') + const [handoverDate, setHandoverDate] = useState(todayIso()) + const [contractDate, setContractDate] = useState('') + + /* ── Schritt 4: Zusammenfassung + Erzeugen ── */ + const profile = useApi(() => getBreederProfile(), []) + const profileComplete = profile.data ? isBreederProfileComplete(profile.data) : true + + const [created, setCreated] = useState(null) + const creation = useMutation(() => + createContract({ + contactId: contactId!, + gerbilIds: [...selectedIds], + price: parsePrice(priceText)!, + handoverDate, + contractDate: contractDate || null, + }), + ) + async function onGenerate() { + const result = await creation.run() + if (result.ok) setCreated(result.value) + } + + /* ── Navigation mit Schritt-Validierung ── */ + function goNext() { + if (step === 0 && !contactId) { + setStepError(t.pickContact) + return + } + if (step === 1 && selectedIds.size === 0) { + setStepError(t.animalsRequired) + return + } + if (step === 2) { + if (parsePrice(priceText) === null) { + setStepError(t.priceInvalid) + return + } + if (!handoverDate) { + setStepError(t.handoverLabel) + return + } + } + setStepError(null) + setStep((s) => Math.min(3, s + 1) as Step) + } + function goBack() { + setStepError(null) + setStep((s) => Math.max(0, s - 1) as Step) + } + + /* ── Erfolgsansicht ── */ + if (created) { + return ( +
+

{t.successTitle}

+

{t.successText}

+
+ + {t.downloadDocx} + + + {t.toList} + + +
+
+ ) + } + + const price = parsePrice(priceText) + + return ( +
+

{t.title}

+ + {/* Schritt-Anzeige */} +
    + {t.steps.map((label, i) => ( +
  1. + {i + 1} + {label} +
  2. + ))} +
+ + {stepError &&
{stepError}
} + + {/* ── Schritt 1: Abnehmer ── */} + {step === 0 && ( +
+

{t.pickContact}

+ {contacts.loading &&

{de.common.loading}

} + {contacts.error &&
{contacts.error}
} + {!contacts.loading && ( + <> + setContactSearch(e.target.value)} + /> + {filteredContacts.length === 0 ? ( +

{t.noContacts}

+ ) : ( +
    + {filteredContacts.map((c) => ( +
  • + +
  • + ))} +
+ )} + +
+ {t.orCreateNew} +
+ + + + + {contactCreation.error && ( +
{contactCreation.error}
+ )} + +
+
+ + )} +
+ )} + + {/* ── Schritt 2: Tiere ── */} + {step === 1 && ( +
+

{t.pickAnimals}

+

{t.pickAnimalsHint}

+ {animals.loading &&

{de.common.loading}

} + {animals.error &&
{animals.error}
} + {!animals.loading && animalItems.length === 0 &&

{t.noAnimals}

} +
    + {animalItems.map((g) => ( +
  • + +
  • + ))} +
+
+ )} + + {/* ── Schritt 3: Preis & Datum ── */} + {step === 2 && ( +
+ + + +
+ )} + + {/* ── Schritt 4: Zusammenfassung ── */} + {step === 3 && ( +
+

{t.summaryTitle}

+ {!profileComplete && ( +
+ {t.profileIncomplete} + + {t.profileLink} + +
+ )} +
+
+
{de.pages.vertraege.fields.contact}
+
{selectedContact?.name ?? '—'}
+
+
+
{de.pages.vertraege.fields.animals}
+
{selectedAnimals.map((g) => g.name).join(', ')}
+
+
+
{de.pages.vertraege.fields.price}
+
+ {price !== null + ? `${price.toLocaleString('de-DE', { minimumFractionDigits: 2 })} €` + : '—'} +
+
+
+
{de.pages.vertraege.fields.handoverDate}
+
{formatDate(handoverDate)}
+
+
+
{de.pages.vertraege.fields.contractDate}
+
{formatDate(contractDate || handoverDate)}
+
+
+ {creation.error &&
{creation.error}
} +
+ )} + + {/* ── Navigation ── */} +
+ {step > 0 ? ( + + ) : ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/gerbil-manager-web/src/pages/vertragWizard.css b/gerbil-manager-web/src/pages/vertragWizard.css new file mode 100644 index 0000000..c017699 --- /dev/null +++ b/gerbil-manager-web/src/pages/vertragWizard.css @@ -0,0 +1,142 @@ +/* FEAT-13: Abgabe-Assistent + Vertragsliste — seitenspezifische Stile + (Standing-Rule-2-Muster: eigene Datei statt index.css). */ + +.wizard { + max-width: 40rem; +} + +/* ── Schritt-Anzeige ── */ + +.wizard-steps { + list-style: none; + display: flex; + gap: 0.25rem; + margin: 0.75rem 0 1rem; + padding: 0; +} + +.wizard-step { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.4rem 0.25rem; + border-bottom: 3px solid var(--color-border); + color: var(--color-text-muted); + font-size: 0.72rem; + text-align: center; +} + +.wizard-step--active { + border-bottom-color: var(--color-accent); + color: var(--color-accent); + font-weight: 600; +} + +.wizard-step--done { + border-bottom-color: var(--color-accent-soft); +} + +.wizard-step__number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + border-radius: 50%; + background: var(--color-accent-soft); + color: var(--color-accent); + font-weight: 600; +} + +.wizard-step--active .wizard-step__number { + background: var(--color-accent); + color: #fff; +} + +/* ── Auswahllisten (Kontakte / Tiere) ── */ + +.wizard-panel { + margin: 0.5rem 0 1rem; +} + +.wizard-pick-list { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 22rem; + overflow-y: auto; +} + +.wizard-pick { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2rem 0.6rem; + align-items: center; + padding: 0.6rem 0.8rem; + border: 1px solid var(--color-border); + border-radius: 0.6rem; + background: var(--color-surface); + cursor: pointer; + min-height: 44px; +} + +.wizard-pick:hover { + background: var(--color-accent-soft); +} + +.wizard-pick input { + grid-row: span 2; + width: 1.1rem; + height: 1.1rem; +} + +.wizard-pick__name { + font-weight: 600; +} + +.wizard-pick__meta { + grid-column: 2; + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.wizard-newcontact { + margin-top: 1rem; +} + +.wizard-newcontact summary { + cursor: pointer; + color: var(--color-accent); + min-height: 44px; + display: flex; + align-items: center; +} + +.wizard-actions { + justify-content: space-between; +} + +.wizard-success-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 22rem; + margin-top: 1rem; +} + +/* ── Vertragsliste ── */ + +.vertrag-card { + grid-template-columns: 1fr auto; +} + +.vertrag-card .head-actions { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 81a0a36..997e49c 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -23,6 +23,9 @@ export const de = { more: 'Mehr', // FEAT-12a (Kevin): Abgabe forSale: 'Abgabe', + // FEAT-13 (Kelly): Verträge + Einstellungen + contracts: 'Verträge', + settings: 'Einstellungen', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen', mainNavigation: 'Hauptnavigation', @@ -364,6 +367,90 @@ export const de = { losses: 'Verluste pro Jahr', lossesHint: 'Verstorbene Tiere nach Todesjahr.', }, + // ── FEAT-13 (Kelly): Abgabeverträge ── + vertraege: { + title: 'Abgabeverträge', + newButton: 'Neuer Vertrag', + empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.', + countLabel: 'Verträge', + download: 'Herunterladen', + delete: 'Löschen', + confirmDelete: + 'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.', + animalsCount: (n: number) => (n === 1 ? '1 Tier' : `${n} Tiere`), + fields: { + contact: 'Abnehmer', + animals: 'Tiere', + price: 'Kaufpreis', + handoverDate: 'Übergabedatum', + contractDate: 'Vertragsdatum', + createdAt: 'Erstellt', + }, + // Assistent (/vertraege/neu) + wizard: { + title: 'Abgabe abschließen', + steps: ['Abnehmer', 'Tiere', 'Preis & Datum', 'Vertrag'], + back: 'Zurück', + next: 'Weiter', + cancel: 'Abbrechen', + // Schritt 1 + pickContact: 'Abnehmer auswählen', + searchContact: 'Name suchen …', + noContacts: 'Keine Kontakte gefunden.', + orCreateNew: 'Oder neuen Kontakt anlegen', + createContact: 'Kontakt anlegen und auswählen', + contactNameRequired: 'Bitte einen Namen für den Kontakt eingeben.', + // Schritt 2 + pickAnimals: 'Tiere bestätigen', + pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.', + noAnimals: 'Keine abgebbaren Tiere gefunden.', + animalsRequired: 'Bitte mindestens ein Tier auswählen.', + // Schritt 3 + priceLabel: 'Kaufpreis (€)', + pricePlaceholder: 'z. B. 72,00', + priceInvalid: 'Bitte einen gültigen Preis eingeben (z. B. 72,00).', + handoverLabel: 'Übergabedatum', + contractDateLabel: 'Vertragsdatum (optional, Standard = Übergabedatum)', + // Schritt 4 + summaryTitle: 'Zusammenfassung', + profileIncomplete: + 'Das Zuchtprofil ist unvollständig — Name, Adresse und Ort erscheinen im Vertrag. Jetzt unter Einstellungen ergänzen?', + profileLink: 'Zu den Einstellungen', + generate: 'Vertrag erzeugen', + generating: 'Vertrag wird erzeugt …', + successTitle: 'Vertrag erstellt!', + successText: + 'Die Tiere wurden als „Abgegeben“ markiert (Abnehmer und Abgabedatum gesetzt).', + downloadDocx: 'Vertrag herunterladen (.docx)', + toList: 'Zur Vertragsliste', + anotherOne: 'Weiteren Vertrag erstellen', + }, + }, + // ── FEAT-13 (Kelly): Einstellungen (Zuchtprofil) ── + einstellungen: { + title: 'Einstellungen', + zuchtprofil: { + title: 'Zuchtprofil', + intro: + 'Diese Angaben erscheinen als Verkäufer-Block in jedem Abgabevertrag.', + incompleteHint: + 'Noch unvollständig: Name, Adresse und Ort werden für den Vertrag benötigt.', + fields: { + zuchtName: 'Zuchtname', + name: 'Vor- und Nachname', + nameHint: 'Mit Anrede, z. B. „Frau Erika Muster“.', + address: 'Adresse', + addressHint: 'Straße Nr, PLZ Ort.', + phone: 'Telefon', + email: 'E-Mail', + homepage: 'Homepage', + city: 'Ort (Unterschriftszeile)', + }, + save: 'Speichern', + saving: 'Speichern …', + saved: 'Gespeichert.', + }, + }, // ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ── kontakte: { title: 'Kontakte', @@ -371,9 +458,13 @@ export const de = { empty: 'Keine Kontakte gefunden.', countLabel: 'Kontakte', searchPlaceholder: 'Name suchen …', + // FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt + // (DATA-2-Schema: email/phone/address) — gebraucht für den Käufer-Block der Verträge. fields: { name: 'Name', - contactInfo: 'Kontaktdaten', + email: 'E-Mail', + phone: 'Telefon', + address: 'Adresse', notes: 'Notizen', }, linked: { @@ -390,7 +481,7 @@ export const de = { form: { createTitle: 'Neuen Kontakt anlegen', editTitle: 'Kontakt bearbeiten', - contactInfoHint: 'Telefon, E-Mail oder Adresse — freies Format.', + addressHint: 'Straße Nr, PLZ Ort — so erscheint sie im Abgabevertrag.', save: 'Speichern', cancel: 'Abbrechen', saving: 'Speichern …', From a221e366e65ba31d2fc13cee47b3a982b97d423d Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:34:25 +0200 Subject: [PATCH 3/8] FEAT-13: singular-aware contract count text (1 Vertrag / n Vertraege) Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/src/pages/VertraegeListPage.tsx | 4 +--- gerbil-manager-web/src/strings/de.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/gerbil-manager-web/src/pages/VertraegeListPage.tsx b/gerbil-manager-web/src/pages/VertraegeListPage.tsx index 1238da9..79756dd 100644 --- a/gerbil-manager-web/src/pages/VertraegeListPage.tsx +++ b/gerbil-manager-web/src/pages/VertraegeListPage.tsx @@ -58,9 +58,7 @@ export default function VertraegeListPage() {

{t.title}

-

- {totalCount} {t.countLabel} -

+

{t.countText(totalCount)}

diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 997e49c..89a7041 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -372,7 +372,7 @@ export const de = { title: 'Abgabeverträge', newButton: 'Neuer Vertrag', empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.', - countLabel: 'Verträge', + countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`), download: 'Herunterladen', delete: 'Löschen', confirmDelete: From 78da534c7c306d3e1ce84ff089f6dd0a491b2966 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:46:51 +0200 Subject: [PATCH 4/8] FEAT-13: e2e an strukturierte Kontaktfelder angepasst + 2 Abdeckungstests (drops on rebase once Kelly commits the same patch) --- gerbil-manager-web/e2e/becken-kontakte.spec.ts | 18 +++++++++++++++++- gerbil-manager-web/e2e/mock-data.ts | 7 ++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/gerbil-manager-web/e2e/becken-kontakte.spec.ts b/gerbil-manager-web/e2e/becken-kontakte.spec.ts index 0a01209..be82dcf 100644 --- a/gerbil-manager-web/e2e/becken-kontakte.spec.ts +++ b/gerbil-manager-web/e2e/becken-kontakte.spec.ts @@ -75,13 +75,29 @@ test.describe('Kontakte', () => { await page.getByRole('link', { name: tk.newButton }).click() const name = uniqueName('Kontakt') await page.getByLabel(`${tk.fields.name} *`).fill(name) - await page.getByLabel(tk.fields.contactInfo).fill('test@example.de') + // FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten + await page.getByLabel(tk.fields.email).fill('test@example.de') + await page.getByLabel(tk.fields.phone).fill('0151 9876543') await page.getByRole('button', { name: tk.form.save, exact: true }).click() await expect(page.getByRole('heading', { name })).toBeVisible() await expect(page.getByText(tk.linked.empty)).toBeVisible() + // die gespeicherten Felder erscheinen auf der Detailseite + await expect(page.getByText('test@example.de')).toBeVisible() + await expect(page.getByText('0151 9876543')).toBeVisible() acceptNextDialog(page) await page.getByRole('button', { name: tk.delete.action }).click() await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible() }) + + // FEAT-13: Abdeckung der neuen strukturierten Kontaktfelder + test('Detail zeigt E-Mail, Telefon und Adresse mit deutschen Beschriftungen', async ({ page }) => { + skipUnlessMock() + await page.goto('/kontakte') + await page.getByRole('link', { name: /Zoohandlung Meier/ }).click() + await expect(page.getByText(tk.fields.email, { exact: true })).toBeVisible() + await expect(page.getByText('meier@example.de')).toBeVisible() + await expect(page.getByText(tk.fields.address, { exact: true })).toBeVisible() + await expect(page.getByText('Hauptstraße 1, 12345 Musterstadt')).toBeVisible() + }) }) diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 30f8dab..3a7ebb3 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -105,10 +105,11 @@ export function seedDb(): MockDb { { id: 'enc-leer', name: 'Quarantänebecken', notes: null }, ] + // FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt. const contacts: Contact[] = [ - { id: 'con-meier', name: 'Zoohandlung Meier', contactInfo: 'meier@example.de', notes: null }, - { id: 'con-huber', name: 'Familie Huber', contactInfo: '0151 2345678', notes: null }, - { id: 'con-frei', name: 'Züchterin Frei', contactInfo: null, notes: 'unverknüpft' }, + { id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null }, + { id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null }, + { id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' }, ] const colorVarieties = [ From f86a0441431ceb07e3e81e1fbec6d7912902c4da Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:50:42 +0200 Subject: [PATCH 5/8] EXPORT-1: GET /export - Zip mit export.json (volle Treue) + deutschen Excel-CSVs (Semikolon, BOM, TT.MM.JJJJ) + LIESMICH; 9 Tests (Escaping + API-Round-Trip) --- GerbilManager.Tests/ExportTests.cs | 144 ++++++++++++ .../Endpoints/ExportEndpoints.cs | 36 +++ GerbilManagerWebAPI/Export/ExportService.cs | 212 ++++++++++++++++++ .../GerbilManagerWebAPI.csproj | 5 + GerbilManagerWebAPI/Program.cs | 1 + 5 files changed, 398 insertions(+) create mode 100644 GerbilManager.Tests/ExportTests.cs create mode 100644 GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs create mode 100644 GerbilManagerWebAPI/Export/ExportService.cs diff --git a/GerbilManager.Tests/ExportTests.cs b/GerbilManager.Tests/ExportTests.cs new file mode 100644 index 0000000..3e56061 --- /dev/null +++ b/GerbilManager.Tests/ExportTests.cs @@ -0,0 +1,144 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.Export; +using GerbilManagerWebAPI.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace GerbilManager.Tests +{ + /// + /// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export + /// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile). + /// + public class ExportTests + { + // ── CSV-Escaping ─────────────────────────────────────────────────── + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten + [InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten + [InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln + [InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten + [InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV) + public void Escape_behandelt_Sonderfälle(string? input, string expected) + => Assert.Equal(expected, ExportService.Escape(input)); + + [Fact] + public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen() + { + var csv = ExportService.BuildCsv( + ["Name", "Notizen"], + [["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]); + + var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.Equal("Name;Notizen", lines[0]); + Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]); + Assert.Equal("Bo;", lines[2]); + } + + // ── Round-Trip über die API ──────────────────────────────────────── + + [Fact] + public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten() + { + using var factory = new ApiFactory(); + + // Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht + Guid gerbilId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" }; + var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" }; + var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 }; + var colorVariety = db.ColorVarieties.First(); // HasData-Seed + var gerbil = new Gerbil + { + Id = Guid.NewGuid(), + Name = "Krümel", + Gender = Gender.female, + Status = GerbilStatus.Active, + DateOfBirth = new DateOnly(2025, 3, 12), + LitterId = litter.Id, + EnclosureId = enclosure.Id, + OriginContactId = contact.Id, + ColorVarietyId = colorVariety.Id, + Genotype = "Aa CC Dd EE GG Pp Spsp rere", + ImportSource = "Stammbaum von Akio Kids.xlsx", + }; + gerbilId = gerbil.Id; + db.AddRange(contact, enclosure, litter, gerbil, + new HealthRecord + { + Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15), + Type = HealthRecordType.Vaccination, Description = "Jahresimpfung", + CreatedAt = DateTimeOffset.UtcNow, + }, + new WeightRecord + { + Id = Guid.NewGuid(), GerbilId = gerbil.Id, + Date = new DateOnly(2026, 5, 1), WeightGrams = 78, + }); + await db.SaveChangesAsync(); + } + + var client = factory.CreateClient(); + var response = await client.GetAsync("/export"); + response.EnsureSuccessStatusCode(); + Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); + Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName); + + using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read); + string[] expectedEntries = + ["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv", + "gesundheit.csv", "gewichte.csv", "LIESMICH.txt"]; + foreach (var name in expectedEntries) + Assert.NotNull(zip.GetEntry(name)); + + // export.json: Zähler + volle Treue (Genotyp, Import-Herkunft) + using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _)); + var root = json.RootElement; + Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength()); + Assert.Equal(1, root.GetProperty("litters").GetArrayLength()); + Assert.Equal(1, root.GetProperty("contacts").GetArrayLength()); + Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed + var g = root.GetProperty("gerbils")[0]; + Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString()); + Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString()); + Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString()); + + // tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum + var tiere = ReadEntry(zip, "tiere.csv", out var hadBom); + Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel"); + var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]); + var row = lines[1]; + Assert.Contains("Krümel", row); + Assert.Contains("Weiblich", row); + Assert.Contains("Aktiv", row); + Assert.Contains("12.03.2025", row); + Assert.Contains("Großbecken", row); + Assert.Contains("Wurf K", row); + + // kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen) + var kontakte = ReadEntry(zip, "kontakte.csv", out _); + Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte); + + // LIESMICH erklärt den Foto-Speicherort + Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _)); + } + + private static string ReadEntry(ZipArchive zip, string name, out bool hadBom) + { + using var stream = zip.GetEntry(name)!.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + var bytes = ms.ToArray(); + hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; + return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0)); + } + } +} diff --git a/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs new file mode 100644 index 0000000..7f0ea52 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs @@ -0,0 +1,36 @@ +using GerbilManagerWebAPI.Export; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// EXPORT-1: GET /export — komplette Datensicherung als Zip + /// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos). + /// + public static class ExportEndpoints + { + public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) => + { + var data = new ExportService.ExportData( + await db.Gerbils.AsNoTracking().ToListAsync(ct), + await db.Litters.AsNoTracking().ToListAsync(ct), + await db.Contacts.AsNoTracking().ToListAsync(ct), + await db.Enclosures.AsNoTracking().ToListAsync(ct), + await db.ColorVarieties.AsNoTracking().ToListAsync(ct), + await db.HealthRecords.AsNoTracking().ToListAsync(ct), + await db.WeightRecords.AsNoTracking().ToListAsync(ct), + await db.GerbilPhotos.AsNoTracking().ToListAsync(ct)); + + var today = DateOnly.FromDateTime(DateTime.Now); + var bytes = ExportService.BuildZip(data, today); + return Results.File(bytes, "application/zip", + $"rennmaus-export-{today:yyyy-MM-dd}.zip"); + }) + .WithTags("Export"); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Export/ExportService.cs b/GerbilManagerWebAPI/Export/ExportService.cs new file mode 100644 index 0000000..9f83ff5 --- /dev/null +++ b/GerbilManagerWebAPI/Export/ExportService.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GerbilManagerWebAPI.Models; + +namespace GerbilManagerWebAPI.Export +{ + /// + /// EXPORT-1: user-facing Datenexport — builds a zip containing + /// - export.json (full fidelity: every entity incl. genotypes + import provenance) + /// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers, + /// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel) + /// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip) + /// + /// Pure function over (no DbContext) so the round-trip + /// and escaping tests run without a database. + /// + public static class ExportService + { + public sealed record ExportData( + List Gerbils, + List Litters, + List Contacts, + List Enclosures, + List ColorVarieties, + List HealthRecords, + List WeightRecords, + List Photos); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + WriteIndented = true, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + }; + + // German labels for enum values (CSV is the wife-facing backup format; + // the JSON keeps the enum member names for lossless re-import). + private static readonly Dictionary GenderDe = new() + { + [Gender.unknown] = "Unbekannt", + [Gender.male] = "Männlich", + [Gender.female] = "Weiblich", + }; + + private static readonly Dictionary StatusDe = new() + { + [GerbilStatus.Active] = "Aktiv", + [GerbilStatus.Deceased] = "Verstorben", + [GerbilStatus.GivenAway] = "Abgegeben", + }; + + private static readonly Dictionary HealthTypeDe = new() + { + [HealthRecordType.Examination] = "Untersuchung", + [HealthRecordType.Treatment] = "Behandlung", + [HealthRecordType.Injury] = "Verletzung", + [HealthRecordType.Vaccination] = "Impfung", + [HealthRecordType.Other] = "Sonstiges", + }; + + public static byte[] BuildZip(ExportData data, DateOnly exportDate) + { + var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name); + var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name); + var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name); + var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name); + var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name); + + string? Lookup(Dictionary map, TKey? key) where TKey : struct => + key is null ? null : map.GetValueOrDefault(key.Value); + + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddText(zip, "export.json", JsonSerializer.Serialize(new + { + exportedAt = exportDate.ToString("yyyy-MM-dd"), + data.Gerbils, + data.Litters, + data.Contacts, + data.Enclosures, + data.ColorVarieties, + data.HealthRecords, + data.WeightRecords, + data.Photos, + }, JsonOptions), bom: false); + + AddCsv(zip, "tiere.csv", + ["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache", + "Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer", + "Genotyp", "Notizen"], + data.Gerbils.OrderBy(g => g.Name).Select(g => new[] + { + g.Name, GenderDe[g.Gender], StatusDe[g.Status], + De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath, + De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId), + Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId), + Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId), + g.Genotype, g.Notes, + })); + + AddCsv(zip, "wuerfe.csv", + ["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke", + "Voraussichtliches Abgabedatum", "Notizen"], + data.Litters.OrderBy(l => l.Date).Select(l => new[] + { + l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId), + l.TotalBorn?.ToString(CultureInfo.InvariantCulture), + De(l.ExpectedGoHomeDate), l.Notes, + })); + + AddCsv(zip, "kontakte.csv", + ["Name", "E-Mail", "Telefon", "Adresse", "Notizen"], + data.Contacts.OrderBy(c => c.Name).Select(c => new[] + { + c.Name, c.Email, c.Phone, c.Address, c.Notes, + })); + + AddCsv(zip, "gesundheit.csv", + ["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"], + data.HealthRecords.OrderBy(h => h.Date).Select(h => new[] + { + gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date), + HealthTypeDe[h.Type], h.Description, h.Veterinarian, + })); + + AddCsv(zip, "gewichte.csv", + ["Tier", "Datum", "Gewicht (g)", "Notizen"], + data.WeightRecords.OrderBy(w => w.Date).Select(w => new[] + { + gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date), + w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes, + })); + + AddText(zip, "LIESMICH.txt", $""" + Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)} + + Enthalten: + - export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte, + Becken, Farbschläge, Gesundheits- und Gewichtseinträge, + Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft). + - tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice). + - wuerfe.csv : alle Würfe. + - kontakte.csv : alle Kontakte. + - gesundheit.csv: alle Gesundheitseinträge. + - gewichte.csv : alle Gewichtseinträge. + + Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem + deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ. + + FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als + normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage" + neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können + von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier + steht in export.json (Abschnitt "photos"). + """, bom: false); + } + return stream.ToArray(); + } + + // ── CSV building blocks ──────────────────────────────────────────── + + /// "2024-03-12" -> "12.03.2024"; null -> empty. + private static string? De(DateOnly? date) => + date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + private static string De(DateOnly date) => + date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + /// + /// Semicolon-separated (German Excel default), CRLF rows, fields quoted when + /// they contain separator/quote/newline; quotes doubled. + /// + internal static string BuildCsv(string[] header, IEnumerable rows) + { + var sb = new StringBuilder(); + sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n"); + foreach (var row in rows) + sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n"); + return sb.ToString(); + } + + internal static string Escape(string? field) + { + if (string.IsNullOrEmpty(field)) return ""; + return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r') + ? $"\"{field.Replace("\"", "\"\"")}\"" + : field; + } + + private static void AddCsv(ZipArchive zip, string name, string[] header, + IEnumerable rows) => + AddText(zip, name, BuildCsv(header, rows), bom: true); + + private static void AddText(ZipArchive zip, string name, string content, bool bom) + { + var entry = zip.CreateEntry(name, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + if (bom) + { + // UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an. + entryStream.Write(Encoding.UTF8.GetPreamble()); + } + var bytes = Encoding.UTF8.GetBytes(content); + entryStream.Write(bytes); + } + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index af6567e..cd572d9 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -30,4 +30,9 @@ + + + + + diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 897d765..da1a4a6 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -64,6 +64,7 @@ app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); app.MapContractEndpoints(); app.MapSettingsEndpoints(); +app.MapExportEndpoints(); app.Run(); From 8fa02a5d49ee8aa55e30dd336b8e3b1d3794ef49 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:55:14 +0200 Subject: [PATCH 6/8] EXPORT-1: Datenexport-Karte auf /einstellungen (Anker-Download, deutsche Erklaertexte + Foto-Hinweis) --- .../src/components/DatenexportCard.tsx | 21 +++++++++++++++++++ .../src/pages/EinstellungenPage.tsx | 4 ++++ gerbil-manager-web/src/strings/de.ts | 9 ++++++++ 3 files changed, 34 insertions(+) create mode 100644 gerbil-manager-web/src/components/DatenexportCard.tsx diff --git a/gerbil-manager-web/src/components/DatenexportCard.tsx b/gerbil-manager-web/src/components/DatenexportCard.tsx new file mode 100644 index 0000000..28817f8 --- /dev/null +++ b/gerbil-manager-web/src/components/DatenexportCard.tsx @@ -0,0 +1,21 @@ +/** + * EXPORT-1: Datenexport-Karte (auf /einstellungen) — lädt GET /export als Zip. + * Schlichter Anker-Download (kein fetch/Blob nötig); der Server setzt + * Content-Disposition mit Datumsdateinamen. + */ +import { API_BASE_URL } from '../api/client' +import { de } from '../strings/de' + +export default function DatenexportCard() { + const t = de.pages.datenexport + return ( +
+

{t.title}

+

{t.intro}

+

{t.photoNote}

+ + {t.button} + +
+ ) +} diff --git a/gerbil-manager-web/src/pages/EinstellungenPage.tsx b/gerbil-manager-web/src/pages/EinstellungenPage.tsx index a340778..4eaac87 100644 --- a/gerbil-manager-web/src/pages/EinstellungenPage.tsx +++ b/gerbil-manager-web/src/pages/EinstellungenPage.tsx @@ -13,6 +13,8 @@ import { type BreederProfile, } from '../api/settings' import { useApi, useMutation } from '../hooks/useApi' +// EXPORT-1 (Oscar): Datenexport-Karte +import DatenexportCard from '../components/DatenexportCard' export default function EinstellungenPage() { const t = de.pages.einstellungen @@ -95,6 +97,8 @@ export default function EinstellungenPage() { {saved && {tz.saved}}
+ + ) } diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 89a7041..2ff2271 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -563,6 +563,15 @@ export const de = { }, }, }, + // ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ── + datenexport: { + title: 'Datenexport', + intro: + 'Sicherung deiner Daten — alle Tiere, Würfe und Kontakte als Tabellen (CSV für Excel) plus eine vollständige Datensicherung (JSON), gebündelt als Zip.', + button: 'Export herunterladen', + photoNote: + 'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.', + }, }, api: { errors: { From a1549266c3628124cbd4acc784f93c1cf0ba70be Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:55:14 +0200 Subject: [PATCH 7/8] EXPORT-1: e2e - Download-Smoke (/einstellungen) + Mock: /export-Zip, /settings/breeder-profile, URL-Praedikat fuer /api-relative Basis (OPS-1-proof) --- gerbil-manager-web/e2e/einstellungen.spec.ts | 20 ++++++++++ gerbil-manager-web/e2e/mock-api.ts | 40 ++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 gerbil-manager-web/e2e/einstellungen.spec.ts diff --git a/gerbil-manager-web/e2e/einstellungen.spec.ts b/gerbil-manager-web/e2e/einstellungen.spec.ts new file mode 100644 index 0000000..ef9bbae --- /dev/null +++ b/gerbil-manager-web/e2e/einstellungen.spec.ts @@ -0,0 +1,20 @@ +/** EXPORT-1: Einstellungen — Datenexport-Karte + Download-Smoke. */ +import { de, expect, gotoSection, test } from './fixtures' + +const t = de.pages.datenexport + +test('Einstellungen zeigt die Datenexport-Karte und der Download startet', async ({ page }) => { + await gotoSection(page, de.nav.settings) + await expect(page.getByRole('heading', { name: de.pages.einstellungen.title })).toBeVisible() + + // Datenexport-Karte mit deutschem Erklärtext + await expect(page.getByRole('heading', { name: t.title })).toBeVisible() + await expect(page.getByText(t.intro)).toBeVisible() + await expect(page.getByText(t.photoNote)).toBeVisible() + + // Klick startet den Zip-Download + const downloadPromise = page.waitForEvent('download') + await page.getByRole('link', { name: t.button }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toContain('rennmaus-export') +}) diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 240aaa9..2aea058 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -98,11 +98,37 @@ export async function installMockApi(page: Page): Promise { 'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'), } - await page.route(`${API_ORIGIN}/**`, async (route) => { + const handler = async (route: Route) => { const request = route.request() const url = new URL(request.url()) const method = request.method() - const path = url.pathname + // OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy); + // den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert. + const path = url.pathname.replace(/^\/api(?=\/)/, '') + + // EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der + // Download startet; ein leeres Zip = End-of-central-directory-Record). + if (path === '/export' && method === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/zip', + headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' }, + body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]), + }) + } + + // FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern) + if (path === '/settings/breeder-profile') { + if (method === 'GET') { + return json(route, 200, { + zuchtName: 'Zucht der kleinen Chaoten', + name: 'Frau Erika Muster', + address: 'Musterweg 1, 12345 Musterstadt', + phone: '', email: '', homepage: '', city: 'Musterstadt', + }) + } + if (method === 'PUT') return json(route, 204) + } // Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge) let m = path.match(/^\/gerbils\/([^/]+)\/photos$/) @@ -157,7 +183,15 @@ export async function installMockApi(page: Page): Promise { return json(route, 204) } return json(route, 405) - }) + } + + // Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy). + // URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests + // (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten. + await page.route( + (url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'), + handler, + ) return db } From f08dbd22190b9d0604359f1dd9661b386dbdb54e Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:57:27 +0200 Subject: [PATCH 8/8] QA-1: CI-Flake-Schutz - Worker-Cap 4 unter CI (geteilter Vite-Dev-Server, Dwights Beobachtung) --- gerbil-manager-web/playwright.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gerbil-manager-web/playwright.config.ts b/gerbil-manager-web/playwright.config.ts index cc8dff3..bc6f2b9 100644 --- a/gerbil-manager-web/playwright.config.ts +++ b/gerbil-manager-web/playwright.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, + // CI-Flake-Schutz (Dwights Beobachtung, 2× 'Kontakt anlegen'): alle Worker + // teilen sich EINEN Vite-Dev-Server; unbegrenzte Parallelität erzeugt dort + // Timing-Druck (on-demand-Transforms). Lokal bleibt Playwrights Default. + workers: process.env.CI ? 4 : undefined, reporter: [['list']], timeout: 30_000, use: {