Merge branch 'worktree-agent-a02f88e8490625846'
# Conflicts: # GerbilManagerWebAPI/ApplicationContext.cs # GerbilManagerWebAPI/Program.cs # gerbil-manager-web/e2e/mock-data.ts # gerbil-manager-web/src/App.tsx # gerbil-manager-web/src/components/AppShell.tsx # gerbil-manager-web/src/strings/de.ts
This commit is contained in:
208
GerbilManager.Tests/ReturnRecordEndpointTests.cs
Normal file
208
GerbilManager.Tests/ReturnRecordEndpointTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// RÜCKNAHMEN (getback_tb): zurückgekommene/zurückgenommene Tiere.
|
||||
/// - Voller CRUD-Round-Trip (POST/GET[?gerbilId=]/PUT/DELETE).
|
||||
/// - Validierung: ohne Tier (weder GerbilId noch GerbilName) → 400.
|
||||
/// - CRITICAL: Rücknahme-Zeilen überleben den Import-Re-Ingest-Wipe
|
||||
/// (lose, FK-freie GerbilId/FromContactId — wie Feedback).
|
||||
/// </summary>
|
||||
public class ReturnRecordEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public ReturnRecordEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_round_trip_create_list_update_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// POST
|
||||
var resp = await client.PostAsJsonAsync("/returns", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Krümel",
|
||||
returnDate = "2026-05-01T00:00:00Z",
|
||||
returnPrice = 0m,
|
||||
originalPrice = 15m,
|
||||
originalSaleDate = "2025-09-01T00:00:00Z",
|
||||
fromContactId = contactId,
|
||||
fromContactName = "Familie Meier",
|
||||
note = "Allergie in der Familie",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal("Krümel", created.GetProperty("gerbilName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal("Familie Meier", created.GetProperty("fromContactName").GetString());
|
||||
|
||||
// GET (unfiltered) returns it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// GET ?gerbilId= filters
|
||||
var filtered = JsonDocument.Parse(
|
||||
await client.GetStringAsync($"/returns?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.All(filtered.EnumerateArray(),
|
||||
r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString()));
|
||||
Assert.Contains(filtered.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
var other = JsonDocument.Parse(
|
||||
await client.GetStringAsync($"/returns?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.Empty(other.EnumerateArray());
|
||||
|
||||
// PUT updates
|
||||
var putResp = await client.PutAsJsonAsync($"/returns/{id}", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Krümel",
|
||||
returnDate = "2026-05-02T00:00:00Z",
|
||||
returnPrice = 5m,
|
||||
note = "Korrigierter Grund",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, putResp.StatusCode);
|
||||
var updated = JsonDocument.Parse(await putResp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Korrigierter Grund", updated.GetProperty("note").GetString());
|
||||
Assert.Equal(5m, updated.GetProperty("returnPrice").GetDecimal());
|
||||
|
||||
// DELETE removes
|
||||
var delResp = await client.DeleteAsync($"/returns/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
|
||||
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement;
|
||||
Assert.DoesNotContain(afterDelete.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// DELETE again → 404
|
||||
var delAgain = await client.DeleteAsync($"/returns/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_record_without_animal()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/returns", new { note = "irgendwas" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_unknown_id_returns_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PutAsJsonAsync($"/returns/{Guid.NewGuid()}", new { gerbilName = "X" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnRecord_survives_ingest_wipe()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "returns-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("returns-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A return record referencing the gerbil + contact that the wipe will delete.
|
||||
var recordId = Guid.NewGuid();
|
||||
db.ReturnRecords.Add(new ReturnRecord
|
||||
{
|
||||
Id = recordId,
|
||||
GerbilId = fatherId,
|
||||
GerbilName = "Papa",
|
||||
ReturnDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
ReturnPrice = 0m,
|
||||
FromContactId = contactId,
|
||||
FromContactName = "Test Breeder",
|
||||
Note = "kam zurück",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// The gerbils/contacts were wiped & recreated, but the return record is untouched.
|
||||
var survivor = await db.ReturnRecords.SingleAsync(r => r.Id == recordId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(contactId, survivor.FromContactId);
|
||||
Assert.Equal("Papa", survivor.GerbilName);
|
||||
Assert.Equal("Test Breeder", survivor.FromContactName);
|
||||
Assert.Equal(1, await db.ReturnRecords.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||
|
||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
||||
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||
@@ -242,6 +243,19 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<WaitingListEntry>(e =>
|
||||
{
|
||||
e.HasIndex(w => w.CreatedAt);
|
||||
});
|
||||
|
||||
// RÜCKNAHMEN (getback_tb): wie Feedback bewusst beziehungsfrei. GerbilId/
|
||||
// FromContactId sind einfache nullable Guid-Spalten (keine Navigation → EF legt
|
||||
// KEINEN Foreign Key an), damit der Import-Re-Ingest-Wipe von Gerbils/Contacts
|
||||
// diese Zeilen weder löscht noch bricht. Sie überleben den Re-Ingest.
|
||||
modelBuilder.Entity<ReturnRecord>(e =>
|
||||
{
|
||||
e.Property(r => r.ReturnPrice).HasPrecision(10, 2);
|
||||
e.Property(r => r.OriginalPrice).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
e.HasIndex(r => r.CreatedAt);
|
||||
});
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
|
||||
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>RÜCKNAHME: Payload für POST/PUT /returns.</summary>
|
||||
public record ReturnRecordInput(
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note);
|
||||
|
||||
/// <summary>RÜCKNAHME: Antwort-DTO für einen gespeicherten Rücknahme-Datensatz.</summary>
|
||||
public record ReturnRecordDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
104
GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs
Normal file
104
GerbilManagerWebAPI/Endpoints/ReturnRecordEndpoints.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// RÜCKNAHMEN (RPRO3 getback_tb): zurückgekommene/zurückgenommene Tiere.
|
||||
/// GET /returns[?gerbilId=] -> Liste, neueste zuerst (optional nach Tier gefiltert).
|
||||
/// POST /returns -> Rücknahme erfassen, 201.
|
||||
/// PUT /returns/{id} -> Rücknahme aktualisieren, 200.
|
||||
/// DELETE /returns/{id} -> Rücknahme löschen, 204.
|
||||
/// Entkoppelt von Gerbils/Contacts (lose nullable Guid-Spalten, kein FK), daher
|
||||
/// überleben Zeilen den Import-Re-Ingest-Wipe — wie Feedback.
|
||||
/// </summary>
|
||||
public static class ReturnRecordEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReturnRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/returns").WithTags("Returns");
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
|
||||
{
|
||||
var query = db.ReturnRecords.AsNoTracking();
|
||||
if (gerbilId is { } gid)
|
||||
query = query.Where(r => r.GerbilId == gid);
|
||||
// In-Memory sortieren: SQLite (Test-Host) kann nicht nach DateTimeOffset ORDER BY-en.
|
||||
var rows = await query.ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(r => r.ReturnDate ?? DateTime.MinValue)
|
||||
.ThenByDescending(r => r.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<ReturnRecordDto>, BadRequest<string>>> (
|
||||
ReturnRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName))
|
||||
return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden.");
|
||||
|
||||
var entity = new ReturnRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
GerbilName = Trimmed(input.GerbilName),
|
||||
ReturnDate = input.ReturnDate,
|
||||
ReturnPrice = input.ReturnPrice,
|
||||
OriginalPrice = input.OriginalPrice,
|
||||
OriginalSaleDate = input.OriginalSaleDate,
|
||||
FromContactId = input.FromContactId,
|
||||
FromContactName = Trimmed(input.FromContactName),
|
||||
Note = Trimmed(input.Note),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ReturnRecords.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/returns/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<ReturnRecordDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, ReturnRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
|
||||
if (input.GerbilId is null && string.IsNullOrWhiteSpace(input.GerbilName))
|
||||
return TypedResults.BadRequest("Es muss ein Tier (GerbilId oder GerbilName) angegeben werden.");
|
||||
|
||||
entity.GerbilId = input.GerbilId;
|
||||
entity.GerbilName = Trimmed(input.GerbilName);
|
||||
entity.ReturnDate = input.ReturnDate;
|
||||
entity.ReturnPrice = input.ReturnPrice;
|
||||
entity.OriginalPrice = input.OriginalPrice;
|
||||
entity.OriginalSaleDate = input.OriginalSaleDate;
|
||||
entity.FromContactId = input.FromContactId;
|
||||
entity.FromContactName = Trimmed(input.FromContactName);
|
||||
entity.Note = Trimmed(input.Note);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.ReturnRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (entity is null) return TypedResults.NotFound();
|
||||
db.ReturnRecords.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? Trimmed(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static ReturnRecordDto ToDto(ReturnRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.GerbilName, r.ReturnDate, r.ReturnPrice, r.OriginalPrice,
|
||||
r.OriginalSaleDate, r.FromContactId, r.FromContactName, r.Note, r.CreatedAt);
|
||||
}
|
||||
}
|
||||
1595
GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs
generated
Normal file
1595
GerbilManagerWebAPI/Migrations/20260622201557_AddReturnRecord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReturnRecord : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReturnRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
GerbilName = table.Column<string>(type: "text", nullable: true),
|
||||
ReturnDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ReturnPrice = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||
OriginalPrice = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||
OriginalSaleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
FromContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
FromContactName = table.Column<string>(type: "text", nullable: true),
|
||||
Note = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReturnRecords", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReturnRecords_CreatedAt",
|
||||
table: "ReturnRecords",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReturnRecords_GerbilId",
|
||||
table: "ReturnRecords",
|
||||
column: "GerbilId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReturnRecords");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1353,6 +1353,53 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Requests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("FromContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FromContactName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("GerbilName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("OriginalPrice")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<DateTime?>("OriginalSaleDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ReturnDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal?>("ReturnPrice")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("ReturnRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
48
GerbilManagerWebAPI/Models/ReturnRecord.cs
Normal file
48
GerbilManagerWebAPI/Models/ReturnRecord.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// RÜCKNAHME (RPRO3 getback_tb): ein bereits abgegebenes Tier kommt zur Züchterin
|
||||
/// zurück. Bewusst vom restlichen Modell entkoppelt — GerbilId/FromContactId sind
|
||||
/// einfache nullable Guid-Spalten (KEINE Foreign Keys), genau wie bei <see cref="Feedback"/>.
|
||||
/// Dadurch überleben Rücknahmen den Import-Re-Ingest-Wipe (IngestResolvedService löscht
|
||||
/// Gerbils/Contacts, ohne diese Tabelle anzufassen). Der erfasste GerbilName hält den
|
||||
/// Datensatz auch dann lesbar, wenn das referenzierte Tier neu eingespielt/entfernt wurde.
|
||||
/// </summary>
|
||||
public class ReturnRecord
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Lose Referenz (kein FK) auf das zurückgekommene Tier (_TID).</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Erfasster Tiername — überlebt einen Ingest-Wipe (bleibt lesbar).</summary>
|
||||
public string? GerbilName { get; set; }
|
||||
|
||||
/// <summary>Rücknahmedatum (_ZAM).</summary>
|
||||
public DateTime? ReturnDate { get; set; }
|
||||
|
||||
/// <summary>Rücknahmepreis (_ZPREIS) — was die Züchterin bei der Rücknahme zahlte/erstattete.</summary>
|
||||
public decimal? ReturnPrice { get; set; }
|
||||
|
||||
/// <summary>Ursprünglicher Abgabepreis (_PREIS), zur Historie mitgeführt.</summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>Ursprüngliches Abgabedatum (_AM), zur Historie mitgeführt.</summary>
|
||||
public DateTime? OriginalSaleDate { get; set; }
|
||||
|
||||
/// <summary>Lose Referenz (kein FK) auf den Kontakt, von dem das Tier zurückkam (_ABN).</summary>
|
||||
public Guid? FromContactId { get; set; }
|
||||
|
||||
/// <summary>Erfasster Kontaktname — überlebt einen Ingest-Wipe (bleibt lesbar).</summary>
|
||||
public string? FromContactName { get; set; }
|
||||
|
||||
/// <summary>Freitext-Grund/Notiz zur Rücknahme (_BEM).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>Server-seitige Anlagezeit.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ app.MapFeedbackEndpoints();
|
||||
app.MapAcquisitionEndpoints();
|
||||
app.MapSaleReservationEndpoints();
|
||||
app.MapWaitingListEndpoints();
|
||||
app.MapReturnRecordEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -706,6 +706,42 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns, GET optional ?gerbilId=).
|
||||
// Eigene Route (kein Gridify-Paging — die API liefert ein flaches Array).
|
||||
const ret = path.match(/^\/returns(?:\/([^/]+))?$/)
|
||||
if (ret) {
|
||||
const retId = ret[1] ? decodeURIComponent(ret[1]) : null
|
||||
if (!retId) {
|
||||
if (method === 'GET') {
|
||||
const gid = url.searchParams.get('gerbilId')
|
||||
const rows = gid ? db.returns.filter((r) => r.gerbilId === gid) : db.returns
|
||||
return json(route, 200, [...rows].reverse())
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const body = request.postDataJSON() as Row
|
||||
if (!body.gerbilId && !body.gerbilName) {
|
||||
return json(route, 400, 'Es muss ein Tier angegeben werden.')
|
||||
}
|
||||
const created = { id: newId('return'), ...body, createdAt: new Date().toISOString() }
|
||||
db.returns.push(created)
|
||||
return json(route, 201, created)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
const idx = db.returns.findIndex((r) => r.id === retId)
|
||||
if (method === 'PUT') {
|
||||
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||
Object.assign(db.returns[idx], request.postDataJSON() as Row)
|
||||
return json(route, 200, db.returns[idx])
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||
db.returns.splice(idx, 1)
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface MockDb {
|
||||
reservations: Record<string, unknown>[]
|
||||
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
|
||||
waitingList: Record<string, unknown>[]
|
||||
// RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns)
|
||||
returns: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -617,5 +619,6 @@ export function seedDb(): MockDb {
|
||||
createdAt: '2026-04-10T00:00:00Z',
|
||||
},
|
||||
],
|
||||
returns: [],
|
||||
}
|
||||
}
|
||||
|
||||
59
gerbil-manager-web/e2e/ruecknahmen.spec.ts
Normal file
59
gerbil-manager-web/e2e/ruecknahmen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** RÜCKNAHMEN: Verwaltungsseite für zurückgekommene Tiere (/ruecknahmen). */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.ruecknahmen
|
||||
|
||||
test('Rücknahmen: Liste ist anfangs leer und das Formular lässt sich öffnen', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/ruecknahmen')
|
||||
|
||||
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
|
||||
await expect(page.getByText(t.empty)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.newButton }).click()
|
||||
await expect(page.getByRole('heading', { name: t.formTitle })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rücknahmen: erfassen über Formular speichert und zeigt den Eintrag', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/ruecknahmen')
|
||||
|
||||
await page.getByRole('button', { name: t.newButton }).click()
|
||||
|
||||
// Tier + Kontakt aus den Dropdowns wählen, Datum + Grund eintragen.
|
||||
await page.getByRole('combobox', { name: t.fields.gerbil, exact: true }).selectOption({ label: 'Krümel' })
|
||||
await page.getByRole('textbox', { name: t.fields.returnDate, exact: true }).fill('2026-05-01')
|
||||
await page.getByRole('combobox', { name: t.fields.fromContact, exact: true }).selectOption({ label: 'Zoohandlung Meier' })
|
||||
await page.getByRole('textbox', { name: t.fields.note, exact: true }).fill('Allergie in der Familie')
|
||||
|
||||
await page.getByRole('button', { name: t.save }).click()
|
||||
|
||||
// Eintrag erscheint in der Liste mit verlinktem Tiernamen.
|
||||
const card = page.locator('.gerbil-card', { hasText: 'Krümel' })
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toContainText('Allergie in der Familie')
|
||||
await expect(card.getByRole('link', { name: 'Krümel' })).toBeVisible()
|
||||
|
||||
// Im Mock persistiert (gerbilId + note).
|
||||
expect(mockDb).not.toBeNull()
|
||||
const rows = mockDb!.returns
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0]).toMatchObject({ gerbilId: 'kruemel', note: 'Allergie in der Familie' })
|
||||
})
|
||||
|
||||
test('Rücknahmen: Eintrag löschen entfernt ihn aus der Liste', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
// Einen Eintrag über die UI anlegen (Fixture erstellt die DB frisch pro Test).
|
||||
await page.goto('/ruecknahmen')
|
||||
await page.getByRole('button', { name: t.newButton }).click()
|
||||
await page.getByRole('combobox', { name: t.fields.gerbil, exact: true }).selectOption({ label: 'Krümel' })
|
||||
await page.getByRole('button', { name: t.save }).click()
|
||||
await expect(page.locator('.gerbil-card', { hasText: 'Krümel' })).toBeVisible()
|
||||
|
||||
// Löschen bestätigen.
|
||||
page.on('dialog', (d) => d.accept())
|
||||
await page.locator('.gerbil-card', { hasText: 'Krümel' }).getByRole('button', { name: t.delete }).click()
|
||||
|
||||
await expect(page.getByText(t.empty)).toBeVisible()
|
||||
expect(mockDb!.returns.length).toBe(0)
|
||||
})
|
||||
@@ -31,6 +31,7 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
||||
import AnfragenPage from './pages/AnfragenPage'
|
||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||
import WartelistePage from './pages/WartelistePage'
|
||||
import RuecknahmenPage from './pages/RuecknahmenPage'
|
||||
|
||||
function BeckenRedirect() {
|
||||
const { '*': splat } = useParams()
|
||||
@@ -104,6 +105,8 @@ export default function App() {
|
||||
</Route>
|
||||
{/* WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb) */}
|
||||
<Route path="warteliste" element={<WartelistePage />} />
|
||||
{/* RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere */}
|
||||
<Route path="ruecknahmen" element={<RuecknahmenPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
48
gerbil-manager-web/src/api/returns.ts
Normal file
48
gerbil-manager-web/src/api/returns.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** RÜCKNAHMEN: API-Client für zurückgenommene Tiere (/returns). */
|
||||
import { api } from './client'
|
||||
|
||||
const RESOURCE = '/returns'
|
||||
|
||||
/** Payload für POST/PUT /returns. */
|
||||
export interface ReturnRecordInput {
|
||||
gerbilId?: string | null
|
||||
gerbilName?: string | null
|
||||
returnDate?: string | null
|
||||
returnPrice?: number | null
|
||||
originalPrice?: number | null
|
||||
originalSaleDate?: string | null
|
||||
fromContactId?: string | null
|
||||
fromContactName?: string | null
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface ReturnRecord {
|
||||
id: string
|
||||
gerbilId: string | null
|
||||
gerbilName: string | null
|
||||
returnDate: string | null
|
||||
returnPrice: number | null
|
||||
originalPrice: number | null
|
||||
originalSaleDate: string | null
|
||||
fromContactId: string | null
|
||||
fromContactName: string | null
|
||||
note: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function listReturns(gerbilId?: string): Promise<ReturnRecord[]> {
|
||||
const q = gerbilId ? `?gerbilId=${encodeURIComponent(gerbilId)}` : ''
|
||||
return api.get<ReturnRecord[]>(`${RESOURCE}${q}`)
|
||||
}
|
||||
|
||||
export function createReturn(body: ReturnRecordInput): Promise<ReturnRecord> {
|
||||
return api.post<ReturnRecord>(RESOURCE, body)
|
||||
}
|
||||
|
||||
export function updateReturn(id: string, body: ReturnRecordInput): Promise<ReturnRecord> {
|
||||
return api.put<ReturnRecord>(`${RESOURCE}/${id}`, body)
|
||||
}
|
||||
|
||||
export function deleteReturn(id: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/${id}`)
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const SECONDARY: NavItem[] = [
|
||||
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
|
||||
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
|
||||
{ to: '/warteliste', label: de.nav.waitingList, icon: '📝' },
|
||||
// RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere
|
||||
{ to: '/ruecknahmen', label: de.nav.returns, icon: '↩️' },
|
||||
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||
|
||||
353
gerbil-manager-web/src/pages/RuecknahmenPage.tsx
Normal file
353
gerbil-manager-web/src/pages/RuecknahmenPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* RÜCKNAHMEN: Verwaltungsseite für zurückgekommene/zurückgenommene Tiere (/ruecknahmen).
|
||||
* Liste + Inline-Formular zum Erfassen/Bearbeiten. Tier-/Kontakt-Bezüge als Links.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import {
|
||||
createReturn,
|
||||
deleteReturn,
|
||||
listReturns,
|
||||
updateReturn,
|
||||
type ReturnRecord,
|
||||
type ReturnRecordInput,
|
||||
} from '../api/returns'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate } from '../format/labels'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
gerbilId: '',
|
||||
gerbilName: '',
|
||||
returnDate: '',
|
||||
returnPrice: '',
|
||||
originalPrice: '',
|
||||
originalSaleDate: '',
|
||||
fromContactId: '',
|
||||
fromContactName: '',
|
||||
note: '',
|
||||
}
|
||||
type FormState = typeof EMPTY_FORM
|
||||
|
||||
function toInput(form: FormState): ReturnRecordInput {
|
||||
const num = (s: string) => (s.trim() === '' ? null : Number(s))
|
||||
return {
|
||||
gerbilId: form.gerbilId || null,
|
||||
gerbilName: form.gerbilName.trim() || null,
|
||||
returnDate: form.returnDate ? `${form.returnDate}T00:00:00Z` : null,
|
||||
returnPrice: num(form.returnPrice),
|
||||
originalPrice: num(form.originalPrice),
|
||||
originalSaleDate: form.originalSaleDate ? `${form.originalSaleDate}T00:00:00Z` : null,
|
||||
fromContactId: form.fromContactId || null,
|
||||
fromContactName: form.fromContactName.trim() || null,
|
||||
note: form.note.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO/Date-Wert → "YYYY-MM-DD" für <input type=date>. */
|
||||
function isoDateInput(value: string | null): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function fromRecord(r: ReturnRecord): FormState {
|
||||
return {
|
||||
gerbilId: r.gerbilId ?? '',
|
||||
gerbilName: r.gerbilName ?? '',
|
||||
returnDate: isoDateInput(r.returnDate),
|
||||
returnPrice: r.returnPrice == null ? '' : String(r.returnPrice),
|
||||
originalPrice: r.originalPrice == null ? '' : String(r.originalPrice),
|
||||
originalSaleDate: isoDateInput(r.originalSaleDate),
|
||||
fromContactId: r.fromContactId ?? '',
|
||||
fromContactName: r.fromContactName ?? '',
|
||||
note: r.note ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(value: number | null): string {
|
||||
if (value == null) return '—'
|
||||
return `${value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
export default function RuecknahmenPage() {
|
||||
const t = de.pages.ruecknahmen
|
||||
|
||||
const returns = useApi(() => listReturns(), [])
|
||||
const gerbils = useApi(
|
||||
() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }),
|
||||
[],
|
||||
)
|
||||
const contacts = useApi(
|
||||
() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }),
|
||||
[],
|
||||
)
|
||||
|
||||
const gerbilName = useMemo(
|
||||
() => new Map((gerbils.data?.items ?? []).map((g) => [g.id, g.name])),
|
||||
[gerbils.data],
|
||||
)
|
||||
const contactName = useMemo(
|
||||
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
|
||||
[contacts.data],
|
||||
)
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
|
||||
const save = useMutation((body: ReturnRecordInput) =>
|
||||
editingId ? updateReturn(editingId, body) : createReturn(body),
|
||||
)
|
||||
const removal = useMutation((id: string) => deleteReturn(id))
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: string) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
setForm(EMPTY_FORM)
|
||||
setEditingId(null)
|
||||
setValidationError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function startEdit(r: ReturnRecord) {
|
||||
setForm(fromRecord(r))
|
||||
setEditingId(r.id)
|
||||
setValidationError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setValidationError(null)
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setValidationError(null)
|
||||
if (!form.gerbilId && !form.gerbilName.trim()) {
|
||||
setValidationError(t.validationNoAnimal)
|
||||
return
|
||||
}
|
||||
const result = await save.run(toInput(form))
|
||||
if (result.ok) {
|
||||
cancelForm()
|
||||
returns.reload()
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id: string) {
|
||||
if (!window.confirm(t.confirmDelete)) return
|
||||
const result = await removal.run(id)
|
||||
if (result.ok) returns.reload()
|
||||
}
|
||||
|
||||
if (returns.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (returns.error) {
|
||||
return (
|
||||
<section className="page">
|
||||
<h2>{t.title}</h2>
|
||||
<div className="alert alert--error">
|
||||
<span>{returns.error}</span>
|
||||
<button type="button" className="btn" onClick={returns.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const items = returns.data ?? []
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
<p className="muted">{items.length === 0 ? t.subtitle : t.countText(items.length)}</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<div className="head-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={startNew}>
|
||||
{t.newButton}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showForm && (
|
||||
<form className="card" onSubmit={onSubmit} style={{ marginBottom: '1rem' }}>
|
||||
<h3>{editingId ? t.editTitle : t.formTitle}</h3>
|
||||
|
||||
<label>
|
||||
{t.fields.gerbil}
|
||||
<select value={form.gerbilId} onChange={(e) => set('gerbilId', e.target.value)}>
|
||||
<option value="">{t.gerbilPlaceholder}</option>
|
||||
{(gerbils.data?.items ?? []).map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.gerbilName}
|
||||
<input
|
||||
type="text"
|
||||
value={form.gerbilName}
|
||||
onChange={(e) => set('gerbilName', e.target.value)}
|
||||
/>
|
||||
<small className="muted">{t.fields.gerbilNameHint}</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.returnDate}
|
||||
<input
|
||||
type="date"
|
||||
value={form.returnDate}
|
||||
onChange={(e) => set('returnDate', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.returnPrice}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={form.returnPrice}
|
||||
onChange={(e) => set('returnPrice', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.originalPrice}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={form.originalPrice}
|
||||
onChange={(e) => set('originalPrice', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.originalSaleDate}
|
||||
<input
|
||||
type="date"
|
||||
value={form.originalSaleDate}
|
||||
onChange={(e) => set('originalSaleDate', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.fromContact}
|
||||
<select value={form.fromContactId} onChange={(e) => set('fromContactId', e.target.value)}>
|
||||
<option value="">{t.contactPlaceholder}</option>
|
||||
{(contacts.data?.items ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.fromContactName}
|
||||
<input
|
||||
type="text"
|
||||
value={form.fromContactName}
|
||||
onChange={(e) => set('fromContactName', e.target.value)}
|
||||
/>
|
||||
<small className="muted">{t.fields.fromContactNameHint}</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t.fields.note}
|
||||
<textarea
|
||||
value={form.note}
|
||||
placeholder={t.fields.notePlaceholder}
|
||||
onChange={(e) => set('note', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{validationError && <div className="alert alert--error">{validationError}</div>}
|
||||
{save.error && <div className="alert alert--error">{t.saveError}</div>}
|
||||
|
||||
<div className="head-actions">
|
||||
<button type="submit" className="btn btn--primary" disabled={save.pending}>
|
||||
{save.pending ? t.saving : t.save}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelForm} disabled={save.pending}>
|
||||
{t.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{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((r) => {
|
||||
const displayName = r.gerbilId
|
||||
? (gerbilName.get(r.gerbilId) ?? r.gerbilName ?? t.unknownAnimal)
|
||||
: (r.gerbilName ?? t.unknownAnimal)
|
||||
const displayContact = r.fromContactId
|
||||
? (contactName.get(r.fromContactId) ?? r.fromContactName)
|
||||
: r.fromContactName
|
||||
return (
|
||||
<li key={r.id} className="gerbil-card">
|
||||
<span className="gerbil-card__name">
|
||||
{r.gerbilId ? (
|
||||
<Link to={`/rennmaeuse/${r.gerbilId}`}>{displayName}</Link>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">
|
||||
{t.returnedOn} {formatDate(isoDateInput(r.returnDate) || null)}
|
||||
{displayContact && (
|
||||
<>
|
||||
{' · '}
|
||||
{t.from}{' '}
|
||||
{r.fromContactId ? (
|
||||
<Link to={`/kontakte/${r.fromContactId}`}>{displayContact}</Link>
|
||||
) : (
|
||||
displayContact
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{r.returnPrice != null && <> · {formatPrice(r.returnPrice)}</>}
|
||||
{r.note && <> · {r.note}</>}
|
||||
</span>
|
||||
<span className="head-actions">
|
||||
<button type="button" className="btn" onClick={() => startEdit(r)}>
|
||||
{t.edit}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => onDelete(r.id)}
|
||||
disabled={removal.pending}
|
||||
>
|
||||
{t.delete}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export const de = {
|
||||
requests: 'Anfragen',
|
||||
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
|
||||
waitingList: 'Warteliste',
|
||||
// RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere
|
||||
returns: 'Rücknahmen',
|
||||
openMenu: 'Menü öffnen',
|
||||
closeMenu: 'Menü schließen',
|
||||
mainNavigation: 'Hauptnavigation',
|
||||
@@ -1045,6 +1047,48 @@ export const de = {
|
||||
validationName: 'Bitte einen Kontakt wählen oder einen Namen eingeben.',
|
||||
noContactLink: 'Kein Kontakt verknüpft',
|
||||
},
|
||||
// ── RÜCKNAHMEN: zurückgekommene/zurückgenommene Tiere (getback_tb) ──
|
||||
ruecknahmen: {
|
||||
title: 'Rücknahmen',
|
||||
subtitle: 'Tiere, die zur Zucht zurückgekommen sind',
|
||||
countText: (n: number) =>
|
||||
n === 1 ? '1 Rücknahme erfasst' : `${n} Rücknahmen erfasst`,
|
||||
empty: 'Noch keine Rücknahmen erfasst.',
|
||||
newButton: 'Rücknahme erfassen',
|
||||
// Formular
|
||||
formTitle: 'Rücknahme erfassen',
|
||||
editTitle: 'Rücknahme bearbeiten',
|
||||
fields: {
|
||||
gerbil: 'Tier',
|
||||
gerbilName: 'Tiername',
|
||||
gerbilNameHint: 'Falls das Tier nicht in der Liste steht, hier den Namen eintragen.',
|
||||
returnDate: 'Rücknahmedatum',
|
||||
returnPrice: 'Rücknahmepreis (€)',
|
||||
originalPrice: 'Ursprünglicher Abgabepreis (€)',
|
||||
originalSaleDate: 'Ursprüngliches Abgabedatum',
|
||||
fromContact: 'Zurück von (Kontakt)',
|
||||
fromContactName: 'Name (frei)',
|
||||
fromContactNameHint: 'Falls der Kontakt nicht in der Liste steht, hier den Namen eintragen.',
|
||||
note: 'Grund / Notiz',
|
||||
notePlaceholder: 'Warum kam das Tier zurück?',
|
||||
},
|
||||
gerbilPlaceholder: '— Tier wählen —',
|
||||
contactPlaceholder: '— Kontakt wählen —',
|
||||
// Listen-Karte
|
||||
returnedOn: 'zurück am',
|
||||
from: 'von',
|
||||
unknownAnimal: '(unbekanntes Tier)',
|
||||
// Aktionen
|
||||
save: 'Speichern',
|
||||
saving: 'Wird gespeichert …',
|
||||
edit: 'Bearbeiten',
|
||||
cancel: 'Abbrechen',
|
||||
delete: 'Löschen',
|
||||
confirmDelete: 'Diese Rücknahme wirklich löschen?',
|
||||
validationNoAnimal: 'Bitte ein Tier wählen oder einen Tiernamen eintragen.',
|
||||
saveSuccess: 'Rücknahme gespeichert.',
|
||||
saveError: 'Rücknahme konnte nicht gespeichert werden.',
|
||||
},
|
||||
},
|
||||
// ── HELP-1: In-App-Anleitung ──
|
||||
hilfe: {
|
||||
|
||||
Reference in New Issue
Block a user