Merge feature/feat-13: Abgabevertrag phase B — SaleContract+wizard, /einstellungen Zuchtprofil, contract endpoints w/ transactional Abgabe, Kontakt structured fields [god-QA: 40/40+47/47+e2e 56/56]
# Conflicts: # GerbilManager.Tests/GerbilManager.Tests.csproj # GerbilManagerWebAPI/Program.cs
This commit is contained in:
61
GerbilManager.Tests/ApiFactory.cs
Normal file
61
GerbilManager.Tests/ApiFactory.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
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<T> — ohne deren Entfernung blieben
|
||||||
|
// Npgsql UND SQLite registriert (ein Provider pro ServiceProvider).
|
||||||
|
services.RemoveAll<Microsoft.EntityFrameworkCore.Infrastructure.IDbContextOptionsConfiguration<ApplicationContext>>();
|
||||||
|
services.RemoveAll<DbContextOptions<ApplicationContext>>();
|
||||||
|
services.RemoveAll<ApplicationContext>();
|
||||||
|
|
||||||
|
_connection.Open();
|
||||||
|
services.AddDbContext<ApplicationContext>(o => o.UseSqlite(_connection));
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
using var scope = provider.CreateScope();
|
||||||
|
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_connection.Dispose();
|
||||||
|
if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class ContractEndpointTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
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<Guid> GerbilIds, string Url);
|
||||||
|
|
||||||
|
private sealed record Paged<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||||
|
|
||||||
|
private async Task<Guid> 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<IdDto>(Json))!.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> 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<IdDto>(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<Dictionary<string, string>>("/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<ContractView>(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<GerbilView>($"/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<Paged<ContractView>>(
|
||||||
|
$"/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<GerbilView>($"/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<Guid>(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sichtbarer Text aus word/document.xml (Tags entfernt).</summary>
|
||||||
|
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(), "<[^>]+>", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||||
|
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||||
|
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -59,6 +61,31 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasOne<Gerbil>().WithMany()
|
e.HasOne<Gerbil>().WithMany()
|
||||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||||
|
|
||||||
|
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||||
|
modelBuilder.Entity<SaleContract>(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<SaleContractAnimal>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
|
||||||
|
e.HasOne<SaleContract>().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<BreederSettings>()
|
||||||
|
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
|
||||||
|
|
||||||
SeedColorVarieties(modelBuilder);
|
SeedColorVarieties(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
33
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
@@ -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<Guid> GerbilIds,
|
||||||
|
// Download-URL der .docx ("/contracts/{id}/file").
|
||||||
|
string Url);
|
||||||
|
|
||||||
|
public record SaleContractInput(
|
||||||
|
Guid ContactId,
|
||||||
|
List<Guid> GerbilIds,
|
||||||
|
decimal Price,
|
||||||
|
DateOnly HandoverDate,
|
||||||
|
DateOnly? ContractDate);
|
||||||
|
|
||||||
|
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||||
|
public record BreederProfileDto(
|
||||||
|
string ZuchtName,
|
||||||
|
string Name,
|
||||||
|
string Address,
|
||||||
|
string Phone,
|
||||||
|
string Email,
|
||||||
|
string Homepage,
|
||||||
|
string City);
|
||||||
|
}
|
||||||
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
/// </summary>
|
||||||
|
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<Results<Ok<SaleContractDto>, 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<Results<Created<SaleContractDto>, ValidationProblem>> (
|
||||||
|
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var errors = new Dictionary<string, string[]>();
|
||||||
|
|
||||||
|
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<Results<PhysicalFileHttpResult, NotFound>> (
|
||||||
|
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<Results<NoContent, NotFound>> (
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).</summary>
|
||||||
|
internal static string GeschlechtText(Gender gender) => gender switch
|
||||||
|
{
|
||||||
|
Gender.male => "Männlich",
|
||||||
|
Gender.female => "Weiblich",
|
||||||
|
_ => "Unbekannt",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').</summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
60
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
60
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
public static class SettingsEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/settings").WithTags("Settings");
|
||||||
|
|
||||||
|
group.MapGet("/breeder-profile", async Task<Ok<BreederProfileDto>> (ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var s = await LoadAsync(db, track: false);
|
||||||
|
return TypedResults.Ok(ToDto(s));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/breeder-profile", async Task<NoContent> (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt.</summary>
|
||||||
|
private static async Task<BreederSettings> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1024
GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs
generated
Normal file
1024
GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SaleContractsAndBreederSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BreederSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ZuchtName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Address = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Phone = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Email = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Homepage = table.Column<string>(type: "text", nullable: false),
|
||||||
|
City = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_BreederSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SaleContracts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ContactId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
|
||||||
|
HandoverDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
ContractDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BreederSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleContractAnimal");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleContracts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,58 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Homepage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("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 =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -777,6 +829,54 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("Litters");
|
b.ToTable("Litters");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ContactId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("ContractDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("HandoverDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<decimal>("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<Guid>("SaleContractId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("GerbilId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("SaleContractId", "GerbilId");
|
||||||
|
|
||||||
|
b.HasIndex("GerbilId");
|
||||||
|
|
||||||
|
b.ToTable("SaleContractAnimal");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -875,6 +975,34 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Navigation("Mother");
|
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 =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||||
@@ -888,6 +1016,11 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Gerbils");
|
b.Navigation("Gerbils");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Animals");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
GerbilManagerWebAPI/Models/BreederSettings.cs
Normal file
36
GerbilManagerWebAPI/Models/BreederSettings.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class BreederSettings
|
||||||
|
{
|
||||||
|
/// <summary>Fixe Id der einzigen Zeile (per HasData geseedet).</summary>
|
||||||
|
public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001");
|
||||||
|
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public string ZuchtName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
|
||||||
|
public string Address { get; set; } = "";
|
||||||
|
|
||||||
|
public string Phone { get; set; } = "";
|
||||||
|
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
|
||||||
|
public string Homepage { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
|
||||||
|
public string City { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
45
GerbilManagerWebAPI/Models/SaleContract.cs
Normal file
45
GerbilManagerWebAPI/Models/SaleContract.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="SaleContractAnimal"/>
|
||||||
|
/// (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).
|
||||||
|
/// </summary>
|
||||||
|
public class SaleContract
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Abnehmer (Käufer-Block des Vertrags).</summary>
|
||||||
|
public Guid ContactId { get; set; }
|
||||||
|
public Contact? Contact { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Kaufpreis in Euro (gesamt).</summary>
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
public DateOnly HandoverDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Datum der Unterschriftszeile (Standard: Übergabedatum).</summary>
|
||||||
|
public DateOnly ContractDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Dateiname der erzeugten .docx im Vertrags-Dateiroot.</summary>
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public List<SaleContractAnimal> Animals { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel).</summary>
|
||||||
|
public class SaleContractAnimal
|
||||||
|
{
|
||||||
|
public Guid SaleContractId { get; set; }
|
||||||
|
public Guid GerbilId { get; set; }
|
||||||
|
public Gerbil? Gerbil { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,8 +51,13 @@ app.MapOpenApi();
|
|||||||
app.MapScalarApiReference();
|
app.MapScalarApiReference();
|
||||||
|
|
||||||
// Apply EF migrations at startup (no-op if schema is current; safe for single-instance deploy).
|
// 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<ApplicationContext>().Database.Migrate();
|
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseCors(LanCorsPolicy);
|
app.UseCors(LanCorsPolicy);
|
||||||
|
|
||||||
@@ -68,5 +73,10 @@ app.MapInbreedingEndpoints();
|
|||||||
app.MapPhotoEndpoints();
|
app.MapPhotoEndpoints();
|
||||||
app.MapSaleAdEndpoints();
|
app.MapSaleAdEndpoints();
|
||||||
app.MapImportEndpoints();
|
app.MapImportEndpoints();
|
||||||
|
app.MapContractEndpoints();
|
||||||
|
app.MapSettingsEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
// Sichtbarer Programmtyp für WebApplicationFactory<Program> (Endpoint-Tests).
|
||||||
|
public partial class Program { }
|
||||||
|
|||||||
@@ -75,13 +75,29 @@ test.describe('Kontakte', () => {
|
|||||||
await page.getByRole('link', { name: tk.newButton }).click()
|
await page.getByRole('link', { name: tk.newButton }).click()
|
||||||
const name = uniqueName('Kontakt')
|
const name = uniqueName('Kontakt')
|
||||||
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
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 page.getByRole('button', { name: tk.form.save, exact: true }).click()
|
||||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
await expect(page.getByText(tk.linked.empty)).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)
|
acceptNextDialog(page)
|
||||||
await page.getByRole('button', { name: tk.delete.action }).click()
|
await page.getByRole('button', { name: tk.delete.action }).click()
|
||||||
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,10 +105,11 @@ export function seedDb(): MockDb {
|
|||||||
{ id: 'enc-leer', name: 'Quarantänebecken', notes: null },
|
{ id: 'enc-leer', name: 'Quarantänebecken', notes: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||||
const contacts: Contact[] = [
|
const contacts: Contact[] = [
|
||||||
{ id: 'con-meier', name: 'Zoohandlung Meier', contactInfo: 'meier@example.de', notes: null },
|
{ 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', contactInfo: '0151 2345678', notes: null },
|
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
|
||||||
{ id: 'con-frei', name: 'Züchterin Frei', contactInfo: null, notes: 'unverknüpft' },
|
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const colorVarieties = [
|
const colorVarieties = [
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import NotFoundPage from './pages/NotFoundPage'
|
|||||||
import StammbaumPage from './pages/StammbaumPage'
|
import StammbaumPage from './pages/StammbaumPage'
|
||||||
import StatistikPage from './pages/StatistikPage'
|
import StatistikPage from './pages/StatistikPage'
|
||||||
import HilfePage from './pages/HilfePage'
|
import HilfePage from './pages/HilfePage'
|
||||||
|
import VertraegeListPage from './pages/VertraegeListPage'
|
||||||
|
import VertragWizardPage from './pages/VertragWizardPage'
|
||||||
|
import EinstellungenPage from './pages/EinstellungenPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -56,6 +59,12 @@ export default function App() {
|
|||||||
<Route path="abgabe" element={<AbgabePage />} />
|
<Route path="abgabe" element={<AbgabePage />} />
|
||||||
<Route path="statistik" element={<StatistikPage />} />
|
<Route path="statistik" element={<StatistikPage />} />
|
||||||
<Route path="hilfe" element={<HilfePage />} />
|
<Route path="hilfe" element={<HilfePage />} />
|
||||||
|
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
||||||
|
<Route path="vertraege">
|
||||||
|
<Route index element={<VertraegeListPage />} />
|
||||||
|
<Route path="neu" element={<VertragWizardPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="einstellungen" element={<EinstellungenPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -69,4 +69,5 @@ export const resources = {
|
|||||||
contacts: '/contacts',
|
contacts: '/contacts',
|
||||||
enclosures: '/enclosures',
|
enclosures: '/enclosures',
|
||||||
colorVarieties: '/color-varieties',
|
colorVarieties: '/color-varieties',
|
||||||
|
contracts: '/contracts',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { Contact, Paged } from './types'
|
|||||||
/** Payload for POST /contacts. */
|
/** Payload for POST /contacts. */
|
||||||
export interface CreateContact {
|
export interface CreateContact {
|
||||||
name: string
|
name: string
|
||||||
contactInfo?: string | null
|
email?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
address?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
gerbil-manager-web/src/api/contracts.ts
Normal file
42
gerbil-manager-web/src/api/contracts.ts
Normal file
@@ -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<Paged<SaleContract>> {
|
||||||
|
return api.get<Paged<SaleContract>>(`${resources.contracts}${toQueryString(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContract(body: CreateSaleContract): Promise<SaleContract> {
|
||||||
|
return api.post<SaleContract>(resources.contracts, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteContract(id: string): Promise<void> {
|
||||||
|
return api.delete(`${resources.contracts}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute Download-URL der .docx (für <a href> / window.open). */
|
||||||
|
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
|
||||||
|
return `${API_BASE_URL}${contract.url}`
|
||||||
|
}
|
||||||
36
gerbil-manager-web/src/api/settings.ts
Normal file
36
gerbil-manager-web/src/api/settings.ts
Normal file
@@ -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<BreederProfile> {
|
||||||
|
return api.get<BreederProfile>('/settings/breeder-profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putBreederProfile(profile: BreederProfile): Promise<void> {
|
||||||
|
return api.put<void>('/settings/breeder-profile', profile)
|
||||||
|
}
|
||||||
@@ -73,7 +73,9 @@ export interface Enclosure {
|
|||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
contactInfo: string | null
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
address: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const SECONDARY: NavItem[] = [
|
|||||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||||
{ to: '/statistik', label: de.nav.statistics, 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: '❓' },
|
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
100
gerbil-manager-web/src/pages/EinstellungenPage.tsx
Normal file
100
gerbil-manager-web/src/pages/EinstellungenPage.tsx
Normal file
@@ -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<BreederProfile>(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 = <K extends keyof BreederProfile>(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 <p className="muted">{de.common.loading}</p>
|
||||||
|
if (existing.error) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{existing.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={existing.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = (
|
||||||
|
key: keyof BreederProfile,
|
||||||
|
label: string,
|
||||||
|
hint?: string,
|
||||||
|
type: string = 'text',
|
||||||
|
) => (
|
||||||
|
<label className="field">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input className="input" type={type} value={form[key]} onChange={(e) => set(key, e.target.value)} />
|
||||||
|
{hint && <small className="muted">{hint}</small>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
|
||||||
|
<h3>{tz.title}</h3>
|
||||||
|
<p className="muted">{tz.intro}</p>
|
||||||
|
{!isBreederProfileComplete(form) && <div className="alert alert--error">{tz.incompleteHint}</div>}
|
||||||
|
|
||||||
|
<form className="form" onSubmit={onSubmit} noValidate>
|
||||||
|
{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 && <div className="alert alert--error">{mutation.error}</div>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
||||||
|
{mutation.pending ? tz.saving : tz.save}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="muted">{tz.saved}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -113,6 +113,12 @@ export default function GerbilDetailPage() {
|
|||||||
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
|
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
|
||||||
{de.pages.stammbaum.openButton}
|
{de.pages.stammbaum.openButton}
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */}
|
||||||
|
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
|
||||||
|
<Link to={`/vertraege/neu?tiere=${g.id}`} className="btn">
|
||||||
|
{de.pages.vertraege.wizard.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link to="/rennmaeuse" className="btn">
|
<Link to="/rennmaeuse" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -97,8 +97,16 @@ export default function KontaktDetailPage() {
|
|||||||
|
|
||||||
<dl className="def-list">
|
<dl className="def-list">
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{t.fields.contactInfo}</dt>
|
<dt>{t.fields.email}</dt>
|
||||||
<dd>{c.contactInfo ?? '—'}</dd>
|
<dd>{c.email ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{t.fields.phone}</dt>
|
||||||
|
<dd>{c.phone ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{t.fields.address}</dt>
|
||||||
|
<dd>{c.address ?? '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{t.fields.notes}</dt>
|
<dt>{t.fields.notes}</dt>
|
||||||
|
|||||||
@@ -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 { useState, type FormEvent } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
@@ -7,11 +8,13 @@ import { useApi, useMutation } from '../hooks/useApi'
|
|||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
contactInfo: string
|
email: string
|
||||||
|
phone: string
|
||||||
|
address: string
|
||||||
notes: string
|
notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = { name: '', contactInfo: '', notes: '' }
|
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' }
|
||||||
|
|
||||||
/** "" -> null, sonst der Wert. */
|
/** "" -> null, sonst der Wert. */
|
||||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||||
@@ -33,7 +36,9 @@ export default function KontaktFormPage() {
|
|||||||
setInitializedFor(existing.data.id)
|
setInitializedFor(existing.data.id)
|
||||||
setForm({
|
setForm({
|
||||||
name: existing.data.name,
|
name: existing.data.name,
|
||||||
contactInfo: existing.data.contactInfo ?? '',
|
email: existing.data.email ?? '',
|
||||||
|
phone: existing.data.phone ?? '',
|
||||||
|
address: existing.data.address ?? '',
|
||||||
notes: existing.data.notes ?? '',
|
notes: existing.data.notes ?? '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -54,7 +59,9 @@ export default function KontaktFormPage() {
|
|||||||
setErrors({})
|
setErrors({})
|
||||||
const result = await mutation.run({
|
const result = await mutation.run({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
contactInfo: nn(form.contactInfo),
|
email: nn(form.email),
|
||||||
|
phone: nn(form.phone),
|
||||||
|
address: nn(form.address),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
})
|
})
|
||||||
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
||||||
@@ -80,13 +87,33 @@ export default function KontaktFormPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.contactInfo}</span>
|
<span>{t.fields.email}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={form.contactInfo}
|
type="email"
|
||||||
onChange={(e) => set('contactInfo', e.target.value)}
|
value={form.email}
|
||||||
|
onChange={(e) => set('email', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<small className="muted">{t.form.contactInfoHint}</small>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.phone}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => set('phone', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.address}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => set('address', e.target.value)}
|
||||||
|
/>
|
||||||
|
<small className="muted">{t.form.addressHint}</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ export default function KontaktePage() {
|
|||||||
<li key={c.id}>
|
<li key={c.id}>
|
||||||
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{c.name}</span>
|
<span className="gerbil-card__name">{c.name}</span>
|
||||||
<span className="gerbil-card__meta">{c.contactInfo ?? ''}</span>
|
<span className="gerbil-card__meta">
|
||||||
|
{[c.phone, c.email].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
121
gerbil-manager-web/src/pages/VertraegeListPage.tsx
Normal file
121
gerbil-manager-web/src/pages/VertraegeListPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
|
||||||
|
import { listContactsPaged } from '../api/contacts'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { formatDate } from '../format/labels'
|
||||||
|
import './vertragWizard.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return `${price.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VertraegeListPage() {
|
||||||
|
const t = de.pages.vertraege
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const contracts = useApi(
|
||||||
|
() => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc' }),
|
||||||
|
[page],
|
||||||
|
)
|
||||||
|
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||||
|
const contactName = useMemo(
|
||||||
|
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
|
||||||
|
[contacts.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removal = useMutation((id: string) => deleteContract(id))
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm(t.confirmDelete)) return
|
||||||
|
const result = await removal.run(id)
|
||||||
|
if (result.ok) contracts.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contracts.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
if (contracts.error || !contracts.data) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{contracts.error}</span>
|
||||||
|
<button type="button" className="btn" onClick={contracts.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items, totalCount } = contracts.data
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<p className="muted">{t.countText(totalCount)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="head-actions">
|
||||||
|
<Link to="/vertraege/neu" className="btn btn--primary">
|
||||||
|
{t.newButton}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{removal.error && <div className="alert alert--error">{removal.error}</div>}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="card-list">
|
||||||
|
{items.map((c) => (
|
||||||
|
<li key={c.id} className="gerbil-card vertrag-card">
|
||||||
|
<span className="gerbil-card__name">{contactName.get(c.contactId) ?? '—'}</span>
|
||||||
|
<span className="gerbil-card__meta">
|
||||||
|
{t.animalsCount(c.gerbilIds.length)} · {formatPrice(c.price)} ·{' '}
|
||||||
|
{t.fields.handoverDate} {formatDate(c.handoverDate)}
|
||||||
|
</span>
|
||||||
|
<span className="head-actions">
|
||||||
|
<a className="btn" href={contractFileUrl(c)}>
|
||||||
|
{t.download}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger"
|
||||||
|
onClick={() => onDelete(c.id)}
|
||||||
|
disabled={removal.pending}
|
||||||
|
>
|
||||||
|
{t.delete}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav className="pager" aria-label="Seitennavigation">
|
||||||
|
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
{de.common.previous}
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{de.common.page} {page} {de.common.of} {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
{de.common.next}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
450
gerbil-manager-web/src/pages/VertragWizardPage.tsx
Normal file
450
gerbil-manager-web/src/pages/VertragWizardPage.tsx
Normal file
@@ -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=<id>) und Kevins Abgabe-Gruppen (?tiere=<id1,id2,…>); 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<Step>(0)
|
||||||
|
const [stepError, setStepError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* ── Schritt 1: Abnehmer ── */
|
||||||
|
const [contactId, setContactId] = useState<string | null>(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<Set<string>>(
|
||||||
|
() => 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<SaleContract | null>(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 (
|
||||||
|
<section className="page wizard">
|
||||||
|
<h2>{t.successTitle}</h2>
|
||||||
|
<p>{t.successText}</p>
|
||||||
|
<div className="wizard-success-actions">
|
||||||
|
<a className="btn btn--primary" href={contractFileUrl(created)}>
|
||||||
|
{t.downloadDocx}
|
||||||
|
</a>
|
||||||
|
<Link to="/vertraege" className="btn">
|
||||||
|
{t.toList}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCreated(null)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setPriceText('')
|
||||||
|
setStep(0)
|
||||||
|
animals.reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.anotherOne}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = parsePrice(priceText)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page wizard">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
|
||||||
|
{/* Schritt-Anzeige */}
|
||||||
|
<ol className="wizard-steps">
|
||||||
|
{t.steps.map((label, i) => (
|
||||||
|
<li
|
||||||
|
key={label}
|
||||||
|
className={
|
||||||
|
i === step ? 'wizard-step wizard-step--active' : i < step ? 'wizard-step wizard-step--done' : 'wizard-step'
|
||||||
|
}
|
||||||
|
aria-current={i === step ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span className="wizard-step__number">{i + 1}</span>
|
||||||
|
<span className="wizard-step__label">{label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{stepError && <div className="alert alert--error">{stepError}</div>}
|
||||||
|
|
||||||
|
{/* ── Schritt 1: Abnehmer ── */}
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="wizard-panel">
|
||||||
|
<h3>{t.pickContact}</h3>
|
||||||
|
{contacts.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{contacts.error && <div className="alert alert--error">{contacts.error}</div>}
|
||||||
|
{!contacts.loading && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
placeholder={t.searchContact}
|
||||||
|
value={contactSearch}
|
||||||
|
onChange={(e) => setContactSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{filteredContacts.length === 0 ? (
|
||||||
|
<p className="muted">{t.noContacts}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="wizard-pick-list">
|
||||||
|
{filteredContacts.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<label className="wizard-pick">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="abnehmer"
|
||||||
|
checked={contactId === c.id}
|
||||||
|
onChange={() => setContactId(c.id)}
|
||||||
|
/>
|
||||||
|
<span className="wizard-pick__name">{c.name}</span>
|
||||||
|
<span className="wizard-pick__meta">
|
||||||
|
{[c.phone, c.email].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="wizard-newcontact">
|
||||||
|
<summary>{t.orCreateNew}</summary>
|
||||||
|
<div className="form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{de.pages.kontakte.fields.name} *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={newContact.name}
|
||||||
|
onChange={(e) => setNewContact({ ...newContact, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{de.pages.kontakte.fields.address}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={newContact.address}
|
||||||
|
onChange={(e) => setNewContact({ ...newContact, address: e.target.value })}
|
||||||
|
/>
|
||||||
|
<small className="muted">{de.pages.kontakte.form.addressHint}</small>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{de.pages.kontakte.fields.phone}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="tel"
|
||||||
|
value={newContact.phone}
|
||||||
|
onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{de.pages.kontakte.fields.email}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={newContact.email}
|
||||||
|
onChange={(e) => setNewContact({ ...newContact, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{contactCreation.error && (
|
||||||
|
<div className="alert alert--error">{contactCreation.error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={onCreateContact}
|
||||||
|
disabled={contactCreation.pending}
|
||||||
|
>
|
||||||
|
{t.createContact}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Schritt 2: Tiere ── */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="wizard-panel">
|
||||||
|
<h3>{t.pickAnimals}</h3>
|
||||||
|
<p className="muted">{t.pickAnimalsHint}</p>
|
||||||
|
{animals.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{animals.error && <div className="alert alert--error">{animals.error}</div>}
|
||||||
|
{!animals.loading && animalItems.length === 0 && <p className="muted">{t.noAnimals}</p>}
|
||||||
|
<ul className="wizard-pick-list">
|
||||||
|
{animalItems.map((g) => (
|
||||||
|
<li key={g.id}>
|
||||||
|
<label className="wizard-pick">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(g.id)}
|
||||||
|
onChange={() => toggleAnimal(g.id)}
|
||||||
|
/>
|
||||||
|
<span className="wizard-pick__name">{g.name}</span>
|
||||||
|
<span className="wizard-pick__meta">
|
||||||
|
{[
|
||||||
|
genderLabel(g.gender),
|
||||||
|
g.colorVarietyId ? colorName.get(g.colorVarietyId) : null,
|
||||||
|
g.dateOfBirth ? `* ${formatDate(g.dateOfBirth)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Schritt 3: Preis & Datum ── */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="wizard-panel form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.priceLabel}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder={t.pricePlaceholder}
|
||||||
|
value={priceText}
|
||||||
|
onChange={(e) => setPriceText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.handoverLabel}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={handoverDate}
|
||||||
|
onChange={(e) => setHandoverDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.contractDateLabel}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={contractDate}
|
||||||
|
onChange={(e) => setContractDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Schritt 4: Zusammenfassung ── */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="wizard-panel">
|
||||||
|
<h3>{t.summaryTitle}</h3>
|
||||||
|
{!profileComplete && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{t.profileIncomplete}</span>
|
||||||
|
<Link to="/einstellungen" className="btn">
|
||||||
|
{t.profileLink}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<dl className="def-list">
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{de.pages.vertraege.fields.contact}</dt>
|
||||||
|
<dd>{selectedContact?.name ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{de.pages.vertraege.fields.animals}</dt>
|
||||||
|
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||||
|
<dd>
|
||||||
|
{price !== null
|
||||||
|
? `${price.toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
|
||||||
|
: '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{de.pages.vertraege.fields.handoverDate}</dt>
|
||||||
|
<dd>{formatDate(handoverDate)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<dt>{de.pages.vertraege.fields.contractDate}</dt>
|
||||||
|
<dd>{formatDate(contractDate || handoverDate)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{creation.error && <div className="alert alert--error">{creation.error}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Navigation ── */}
|
||||||
|
<div className="form-actions wizard-actions">
|
||||||
|
{step > 0 ? (
|
||||||
|
<button type="button" className="btn" onClick={goBack}>
|
||||||
|
{t.back}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn" onClick={() => navigate(-1)}>
|
||||||
|
{t.cancel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step < 3 ? (
|
||||||
|
<button type="button" className="btn btn--primary" onClick={goNext}>
|
||||||
|
{t.next}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={creation.pending}
|
||||||
|
>
|
||||||
|
{creation.pending ? t.generating : t.generate}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
gerbil-manager-web/src/pages/vertragWizard.css
Normal file
142
gerbil-manager-web/src/pages/vertragWizard.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ export const de = {
|
|||||||
more: 'Mehr',
|
more: 'Mehr',
|
||||||
// FEAT-12a (Kevin): Abgabe
|
// FEAT-12a (Kevin): Abgabe
|
||||||
forSale: 'Abgabe',
|
forSale: 'Abgabe',
|
||||||
|
// FEAT-13 (Kelly): Verträge + Einstellungen
|
||||||
|
contracts: 'Verträge',
|
||||||
|
settings: 'Einstellungen',
|
||||||
openMenu: 'Menü öffnen',
|
openMenu: 'Menü öffnen',
|
||||||
closeMenu: 'Menü schließen',
|
closeMenu: 'Menü schließen',
|
||||||
mainNavigation: 'Hauptnavigation',
|
mainNavigation: 'Hauptnavigation',
|
||||||
@@ -366,6 +369,90 @@ export const de = {
|
|||||||
losses: 'Verluste pro Jahr',
|
losses: 'Verluste pro Jahr',
|
||||||
lossesHint: 'Verstorbene Tiere nach Todesjahr.',
|
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) ──
|
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
|
||||||
kontakte: {
|
kontakte: {
|
||||||
title: 'Kontakte',
|
title: 'Kontakte',
|
||||||
@@ -373,9 +460,13 @@ export const de = {
|
|||||||
empty: 'Keine Kontakte gefunden.',
|
empty: 'Keine Kontakte gefunden.',
|
||||||
countLabel: 'Kontakte',
|
countLabel: 'Kontakte',
|
||||||
searchPlaceholder: 'Name suchen …',
|
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: {
|
fields: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
contactInfo: 'Kontaktdaten',
|
email: 'E-Mail',
|
||||||
|
phone: 'Telefon',
|
||||||
|
address: 'Adresse',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
},
|
},
|
||||||
linked: {
|
linked: {
|
||||||
@@ -392,7 +483,7 @@ export const de = {
|
|||||||
form: {
|
form: {
|
||||||
createTitle: 'Neuen Kontakt anlegen',
|
createTitle: 'Neuen Kontakt anlegen',
|
||||||
editTitle: 'Kontakt bearbeiten',
|
editTitle: 'Kontakt bearbeiten',
|
||||||
contactInfoHint: 'Telefon, E-Mail oder Adresse — freies Format.',
|
addressHint: 'Straße Nr, PLZ Ort — so erscheint sie im Abgabevertrag.',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
|
|||||||
Reference in New Issue
Block a user