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 17f8e2e..0966c0f 100644 --- a/GerbilManager.Tests/GerbilManager.Tests.csproj +++ b/GerbilManager.Tests/GerbilManager.Tests.csproj @@ -10,6 +10,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 b27de05..d25a52d 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") @@ -777,6 +829,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") @@ -875,6 +975,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) @@ -888,6 +1016,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 335275e..1ae2173 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -51,8 +51,13 @@ app.MapOpenApi(); app.MapScalarApiReference(); // Apply EF migrations at startup (no-op if schema is current; safe for single-instance deploy). -using (var scope = app.Services.CreateScope()) +// Unter "Testing" übersprungen: die Endpoint-Tests laufen auf SQLite in-memory +// (EnsureCreated) — die Npgsql-Migrationen sind dort nicht anwendbar. +if (!app.Environment.IsEnvironment("Testing")) +{ + using var scope = app.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.Migrate(); +} app.UseCors(LanCorsPolicy); @@ -68,5 +73,10 @@ app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); app.MapSaleAdEndpoints(); app.MapImportEndpoints(); +app.MapContractEndpoints(); +app.MapSettingsEndpoints(); app.Run(); + +// Sichtbarer Programmtyp für WebApplicationFactory (Endpoint-Tests). +public partial class Program { } 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 = [ diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index f11a4b0..c5b2f8e 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -19,6 +19,9 @@ import NotFoundPage from './pages/NotFoundPage' import StammbaumPage from './pages/StammbaumPage' import StatistikPage from './pages/StatistikPage' import HilfePage from './pages/HilfePage' +import VertraegeListPage from './pages/VertraegeListPage' +import VertragWizardPage from './pages/VertragWizardPage' +import EinstellungenPage from './pages/EinstellungenPage' export default function App() { return ( @@ -56,6 +59,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 08fc51d..90498e0 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -69,4 +69,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 3a0859d..05d13fe 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: '⚙️' }, { to: '/hilfe', label: de.nav.help, icon: '❓' }, ] 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}

+

{t.countText(totalCount)}

+
+
+ + {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 6dfed61..e7b1ace 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', @@ -366,6 +369,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.', + countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} 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', @@ -373,9 +460,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: { @@ -392,7 +483,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 …',