Merge branch 'worktree-agent-a0e6f31173772c798'
# Conflicts: # GerbilManagerWebAPI/ApplicationContext.cs # GerbilManagerWebAPI/Program.cs # gerbil-manager-web/e2e/mock-data.ts # gerbil-manager-web/src/strings/de.ts
This commit is contained in:
204
GerbilManager.Tests/WaitingListEndpointTests.cs
Normal file
204
GerbilManager.Tests/WaitingListEndpointTests.cs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
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>
|
||||||
|
/// WAITLIST (RennmausPro nachfrage_tb): the prospective-buyer waiting list.
|
||||||
|
/// - Full CRUD round-trip: POST -> GET (list + by id) -> PUT (status change) -> DELETE.
|
||||||
|
/// - Validation: an entry with neither contact nor name, and an unknown status, are 400.
|
||||||
|
/// - CRITICAL: waiting-list rows survive the import re-ingest wipe (loose, FK-free ContactId).
|
||||||
|
/// </summary>
|
||||||
|
public class WaitingListEndpointTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public WaitingListEndpointTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Crud_round_trip()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
var contactId = Guid.NewGuid();
|
||||||
|
var create = await client.PostAsJsonAsync("/waiting-list", new
|
||||||
|
{
|
||||||
|
contactId,
|
||||||
|
contactName = "Familie Sonntag",
|
||||||
|
wishColor = "Schwarz",
|
||||||
|
wishGender = "female",
|
||||||
|
requestedAt = "2026-06-01T00:00:00Z",
|
||||||
|
status = "offen",
|
||||||
|
note = "möchte zwei Weibchen",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||||
|
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
var id = created.GetProperty("id").GetString()!;
|
||||||
|
Assert.Equal("offen", created.GetProperty("status").GetString());
|
||||||
|
Assert.Equal("Schwarz", created.GetProperty("wishColor").GetString());
|
||||||
|
Assert.Equal(contactId.ToString(), created.GetProperty("contactId").GetString());
|
||||||
|
|
||||||
|
// GET by id
|
||||||
|
var byId = JsonDocument.Parse(await client.GetStringAsync($"/waiting-list/{id}")).RootElement;
|
||||||
|
Assert.Equal("Familie Sonntag", byId.GetProperty("contactName").GetString());
|
||||||
|
|
||||||
|
// LIST contains it
|
||||||
|
var list = JsonDocument.Parse(await client.GetStringAsync("/waiting-list")).RootElement;
|
||||||
|
Assert.Contains(list.EnumerateArray(), e => e.GetProperty("id").GetString() == id);
|
||||||
|
|
||||||
|
// UPDATE: set fulfilled
|
||||||
|
var update = await client.PutAsJsonAsync($"/waiting-list/{id}", new
|
||||||
|
{
|
||||||
|
contactId,
|
||||||
|
contactName = "Familie Sonntag",
|
||||||
|
wishColor = "Schwarz",
|
||||||
|
wishGender = "female",
|
||||||
|
requestedAt = "2026-06-01T00:00:00Z",
|
||||||
|
status = "erfuellt",
|
||||||
|
note = "erledigt",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||||
|
var updated = JsonDocument.Parse(await update.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("erfuellt", updated.GetProperty("status").GetString());
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
var del = await client.DeleteAsync($"/waiting-list/{id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||||
|
var after = await client.GetAsync($"/waiting-list/{id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_rejects_empty_contact_and_name()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var resp = await client.PostAsJsonAsync("/waiting-list", new { status = "offen", wishColor = "Gold" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_rejects_unknown_status()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Test", status = "irgendwas" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Empty_status_defaults_to_offen()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Ohne Status" });
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("offen", created.GetProperty("status").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitingList_survives_ingest_wipe()
|
||||||
|
{
|
||||||
|
// Fresh in-memory DB seeded with a resolved import file (mirrors FeedbackEndpointTests).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "waitlist-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("waitlist-ingest-" + Guid.NewGuid().ToString("N"))
|
||||||
|
.Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
// A waiting-list entry referencing the contact that the wipe will delete.
|
||||||
|
var entryId = Guid.NewGuid();
|
||||||
|
db.WaitingListEntries.Add(new WaitingListEntry
|
||||||
|
{
|
||||||
|
Id = entryId,
|
||||||
|
ContactId = contactId,
|
||||||
|
ContactName = "Test Breeder",
|
||||||
|
WishColor = "Schwarz",
|
||||||
|
WishGender = "female",
|
||||||
|
RequestedAt = DateTime.UtcNow,
|
||||||
|
Status = "offen",
|
||||||
|
Note = "wartet",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Run the ingest wipe + reload.
|
||||||
|
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
Assert.Contains("Ingestion successful!", result);
|
||||||
|
|
||||||
|
// Contacts were wiped & re-created, but the waiting-list entry is untouched.
|
||||||
|
var survivor = await db.WaitingListEntries.SingleAsync(e => e.Id == entryId);
|
||||||
|
Assert.Equal(contactId, survivor.ContactId); // loose id preserved even though the contact row was deleted/recreated
|
||||||
|
Assert.Equal("Test Breeder", survivor.ContactName);
|
||||||
|
Assert.Equal("offen", survivor.Status);
|
||||||
|
Assert.Equal(1, await db.WaitingListEntries.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||||
|
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||||
|
|
||||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
// 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.
|
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||||
@@ -234,6 +235,14 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasIndex(r => r.GerbilId);
|
e.HasIndex(r => r.GerbilId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WAITLIST: same relationship-free pattern as Feedback. ContactId is a plain
|
||||||
|
// nullable Guid column (no navigation property → EF creates NO foreign key), so
|
||||||
|
// the import re-ingest wipe of Contacts never cascades into — or breaks —
|
||||||
|
// waiting-list rows. They survive re-ingest, which is the whole point.
|
||||||
|
modelBuilder.Entity<WaitingListEntry>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(w => w.CreatedAt);
|
||||||
|
|
||||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
if (isNpgsql)
|
if (isNpgsql)
|
||||||
{
|
{
|
||||||
|
|||||||
24
GerbilManagerWebAPI/Dtos/WaitingListDtos.cs
Normal file
24
GerbilManagerWebAPI/Dtos/WaitingListDtos.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Dtos
|
||||||
|
{
|
||||||
|
/// <summary>WAITLIST: payload for POST/PUT /waiting-list.</summary>
|
||||||
|
public record WaitingListInput(
|
||||||
|
Guid? ContactId,
|
||||||
|
string? ContactName,
|
||||||
|
string? WishColor,
|
||||||
|
string? WishGender,
|
||||||
|
DateTime? RequestedAt,
|
||||||
|
string? Status,
|
||||||
|
string? Note);
|
||||||
|
|
||||||
|
/// <summary>WAITLIST: response DTO for a stored waiting-list entry.</summary>
|
||||||
|
public record WaitingListDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid? ContactId,
|
||||||
|
string? ContactName,
|
||||||
|
string? WishColor,
|
||||||
|
string? WishGender,
|
||||||
|
DateTime? RequestedAt,
|
||||||
|
string Status,
|
||||||
|
string? Note,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
}
|
||||||
122
GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs
Normal file
122
GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// WAITLIST (RennmausPro nachfrage_tb): a standing list of interested parties waiting
|
||||||
|
/// for a future animal matching their wish criteria (colour, gender).
|
||||||
|
/// GET /waiting-list -> list entries, newest request first
|
||||||
|
/// POST /waiting-list -> create an entry (returns 201)
|
||||||
|
/// PUT /waiting-list/{id} -> update / change status
|
||||||
|
/// DELETE /waiting-list/{id} -> remove an entry
|
||||||
|
/// Decoupled from contacts (loose nullable ContactId, no FK), so rows survive the
|
||||||
|
/// import re-ingest wipe — exactly like Feedback.
|
||||||
|
/// </summary>
|
||||||
|
public static class WaitingListEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Allowed workflow statuses (frontend contract).</summary>
|
||||||
|
private static readonly string[] AllowedStatuses = { "offen", "erfuellt", "storniert" };
|
||||||
|
private const string DefaultStatus = "offen";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapWaitingListEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/waiting-list").WithTags("WaitingList");
|
||||||
|
|
||||||
|
group.MapGet("/", async (ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column,
|
||||||
|
// and RequestedAt is nullable — sort entries with a date first, newest first.
|
||||||
|
var rows = await db.WaitingListEntries.AsNoTracking().ToListAsync();
|
||||||
|
return TypedResults.Ok(rows
|
||||||
|
.OrderByDescending(e => e.RequestedAt ?? DateTime.MinValue)
|
||||||
|
.ThenByDescending(e => e.CreatedAt)
|
||||||
|
.Select(ToDto)
|
||||||
|
.ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:guid}", async Task<Results<Ok<WaitingListDto>, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var entity = await db.WaitingListEntries.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
return entity is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(entity));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async Task<Results<Created<WaitingListDto>, BadRequest<string>>> (
|
||||||
|
WaitingListInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var status = NormalizeStatus(input.Status);
|
||||||
|
if (status is null)
|
||||||
|
return TypedResults.BadRequest("Ungültiger Status.");
|
||||||
|
if (string.IsNullOrWhiteSpace(input.ContactName) && input.ContactId is null)
|
||||||
|
return TypedResults.BadRequest("Kontakt oder Name ist erforderlich.");
|
||||||
|
|
||||||
|
var entity = new WaitingListEntry
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContactId = input.ContactId,
|
||||||
|
ContactName = Trim(input.ContactName),
|
||||||
|
WishColor = Trim(input.WishColor),
|
||||||
|
WishGender = Trim(input.WishGender),
|
||||||
|
RequestedAt = input.RequestedAt,
|
||||||
|
Status = status,
|
||||||
|
Note = Trim(input.Note),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.WaitingListEntries.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Created($"/waiting-list/{entity.Id}", ToDto(entity));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id:guid}", async Task<Results<Ok<WaitingListDto>, NotFound, BadRequest<string>>> (
|
||||||
|
Guid id, WaitingListInput input, ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
var entity = await db.WaitingListEntries.FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
if (entity is null) return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var status = NormalizeStatus(input.Status);
|
||||||
|
if (status is null)
|
||||||
|
return TypedResults.BadRequest("Ungültiger Status.");
|
||||||
|
if (string.IsNullOrWhiteSpace(input.ContactName) && input.ContactId is null)
|
||||||
|
return TypedResults.BadRequest("Kontakt oder Name ist erforderlich.");
|
||||||
|
|
||||||
|
entity.ContactId = input.ContactId;
|
||||||
|
entity.ContactName = Trim(input.ContactName);
|
||||||
|
entity.WishColor = Trim(input.WishColor);
|
||||||
|
entity.WishGender = Trim(input.WishGender);
|
||||||
|
entity.RequestedAt = input.RequestedAt;
|
||||||
|
entity.Status = status;
|
||||||
|
entity.Note = Trim(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.WaitingListEntries.FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
if (entity is null) return TypedResults.NotFound();
|
||||||
|
db.WaitingListEntries.Remove(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Empty/whitespace status defaults to "offen"; unknown values are rejected (null).</summary>
|
||||||
|
private static string? NormalizeStatus(string? status)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(status)) return DefaultStatus;
|
||||||
|
var trimmed = status.Trim();
|
||||||
|
return AllowedStatuses.Contains(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
|
||||||
|
private static WaitingListDto ToDto(WaitingListEntry e) =>
|
||||||
|
new(e.Id, e.ContactId, e.ContactName, e.WishColor, e.WishGender, e.RequestedAt, e.Status, e.Note, e.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
1586
GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs
generated
Normal file
1586
GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWaitingList : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WaitingListEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ContactName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
WishColor = table.Column<string>(type: "text", nullable: true),
|
||||||
|
WishGender = table.Column<string>(type: "text", nullable: true),
|
||||||
|
RequestedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Note = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WaitingListEntries", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WaitingListEntries_CreatedAt",
|
||||||
|
table: "WaitingListEntries",
|
||||||
|
column: "CreatedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WaitingListEntries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1479,6 +1479,44 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ContactId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ContactName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RequestedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("WishColor")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("WishGender")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("WaitingListEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
43
GerbilManagerWebAPI/Models/WaitingListEntry.cs
Normal file
43
GerbilManagerWebAPI/Models/WaitingListEntry.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// WAITLIST (RennmausPro nachfrage_tb): a prospective buyer's standing request for a
|
||||||
|
/// future animal with wish criteria (colour, gender). Decoupled from the rest of the
|
||||||
|
/// model on purpose — ContactId is a plain nullable Guid column (NOT an enforced
|
||||||
|
/// foreign key), so the import re-ingest wipe (IngestResolvedService) can delete and
|
||||||
|
/// recreate contacts without deleting or breaking waiting-list rows. The captured
|
||||||
|
/// ContactName keeps the entry human-readable even when no contact exists yet (or the
|
||||||
|
/// referenced contact is gone). Same survives-the-wipe pattern as Feedback.
|
||||||
|
/// </summary>
|
||||||
|
public class WaitingListEntry
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loose reference (no FK) to the interested contact, if one exists.</summary>
|
||||||
|
public Guid? ContactId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free-text name of the interested party (used when no contact is linked).</summary>
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wished-for colour variety (free text / catalog name), if any.</summary>
|
||||||
|
public string? WishColor { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wished-for gender: "male" | "female" | null (no preference).</summary>
|
||||||
|
public string? WishGender { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the request was made.</summary>
|
||||||
|
public DateTime? RequestedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Workflow status: "offen" | "erfuellt" | "storniert".</summary>
|
||||||
|
public required string Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional free-text note.</summary>
|
||||||
|
public string? Note { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Server-side creation time.</summary>
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ app.MapNamesEndpoints();
|
|||||||
app.MapFeedbackEndpoints();
|
app.MapFeedbackEndpoints();
|
||||||
app.MapAcquisitionEndpoints();
|
app.MapAcquisitionEndpoints();
|
||||||
app.MapSaleReservationEndpoints();
|
app.MapSaleReservationEndpoints();
|
||||||
|
app.MapWaitingListEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -639,6 +639,73 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WAITLIST: Warteliste/Nachfrage — GET liefert ein BLANKES Array (kein Gridify-Envelope),
|
||||||
|
// POST/PUT/DELETE wie eine einfache Kollektion. Vor den generischen Kollektionen, weil
|
||||||
|
// die GET-Antwort kein paginiertes Envelope ist.
|
||||||
|
const wlm = path.match(/^\/waiting-list(?:\/([^/]+))?$/)
|
||||||
|
if (wlm) {
|
||||||
|
const wlId = wlm[1] ? decodeURIComponent(wlm[1]) : null
|
||||||
|
const ALLOWED = ['offen', 'erfuellt', 'storniert']
|
||||||
|
const normStatus = (s: unknown): string | null => {
|
||||||
|
if (s === undefined || s === null || String(s).trim() === '') return 'offen'
|
||||||
|
const v = String(s).trim()
|
||||||
|
return ALLOWED.includes(v) ? v : null
|
||||||
|
}
|
||||||
|
if (!wlId) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
// Neueste Anfrage zuerst (requestedAt desc, dann createdAt desc).
|
||||||
|
const sorted = [...db.waitingList].sort((a, b) => {
|
||||||
|
const ar = String(a.requestedAt ?? '')
|
||||||
|
const br = String(b.requestedAt ?? '')
|
||||||
|
if (ar !== br) return ar < br ? 1 : -1
|
||||||
|
return String(a.createdAt ?? '') < String(b.createdAt ?? '') ? 1 : -1
|
||||||
|
})
|
||||||
|
return json(route, 200, sorted)
|
||||||
|
}
|
||||||
|
if (method === 'POST') {
|
||||||
|
const body = request.postDataJSON() as Record<string, unknown>
|
||||||
|
const status = normStatus(body.status)
|
||||||
|
if (status === null) return json(route, 400, 'Ungültiger Status.')
|
||||||
|
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
|
||||||
|
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
|
||||||
|
const created = {
|
||||||
|
id: newId('wl'),
|
||||||
|
contactId: body.contactId ?? null,
|
||||||
|
contactName: body.contactName ?? null,
|
||||||
|
wishColor: body.wishColor ?? null,
|
||||||
|
wishGender: body.wishGender ?? null,
|
||||||
|
requestedAt: body.requestedAt ?? null,
|
||||||
|
status,
|
||||||
|
note: body.note ?? null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
db.waitingList.push(created)
|
||||||
|
return json(route, 201, created)
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
const wlIdx = db.waitingList.findIndex((r) => r.id === wlId)
|
||||||
|
if (method === 'GET') {
|
||||||
|
return wlIdx >= 0 ? json(route, 200, db.waitingList[wlIdx]) : json(route, 404, { title: 'Not Found' })
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
|
const body = request.postDataJSON() as Record<string, unknown>
|
||||||
|
const status = normStatus(body.status)
|
||||||
|
if (status === null) return json(route, 400, 'Ungültiger Status.')
|
||||||
|
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
|
||||||
|
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
|
||||||
|
Object.assign(db.waitingList[wlIdx], { ...body, status })
|
||||||
|
return json(route, 200, db.waitingList[wlIdx])
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
|
db.waitingList.splice(wlIdx, 1)
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface MockDb {
|
|||||||
acquisitions: Record<string, unknown>[]
|
acquisitions: Record<string, unknown>[]
|
||||||
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations)
|
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations)
|
||||||
reservations: Record<string, unknown>[]
|
reservations: Record<string, unknown>[]
|
||||||
|
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
|
||||||
|
waitingList: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -591,5 +593,29 @@ export function seedDb(): MockDb {
|
|||||||
updatedAt: '2026-06-16T09:00:00Z',
|
updatedAt: '2026-06-16T09:00:00Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
waitingList: [
|
||||||
|
{
|
||||||
|
id: 'wl-seed-1',
|
||||||
|
contactId: contacts[0]?.id ?? null,
|
||||||
|
contactName: contacts[0]?.name ?? 'Familie Sonntag',
|
||||||
|
wishColor: 'Schwarz',
|
||||||
|
wishGender: 'female',
|
||||||
|
requestedAt: '2026-05-01T00:00:00Z',
|
||||||
|
status: 'offen',
|
||||||
|
note: 'möchte zwei Weibchen',
|
||||||
|
createdAt: '2026-05-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wl-seed-2',
|
||||||
|
contactId: null,
|
||||||
|
contactName: 'Herr Maier',
|
||||||
|
wishColor: null,
|
||||||
|
wishGender: 'male',
|
||||||
|
requestedAt: '2026-04-10T00:00:00Z',
|
||||||
|
status: 'erfuellt',
|
||||||
|
note: null,
|
||||||
|
createdAt: '2026-04-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
gerbil-manager-web/e2e/warteliste.spec.ts
Normal file
73
gerbil-manager-web/e2e/warteliste.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** WAITLIST: Warteliste/Nachfrage — Liste, Anlegen, Status setzen, "nur offene" filtern. */
|
||||||
|
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.warteliste
|
||||||
|
|
||||||
|
test('Warteliste ist über die Navigation erreichbar und zeigt Seed-Einträge', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.waitingList)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Seed: ein Kontakt-Link + ein freitextlicher Name.
|
||||||
|
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Herr Maier')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('"Nur offene" filtert erfüllte Einträge aus', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/warteliste')
|
||||||
|
await expect(page.getByText('Herr Maier')).toBeVisible() // erfüllt
|
||||||
|
|
||||||
|
await page.getByLabel(t.onlyOpen).check()
|
||||||
|
await expect(page.getByText('Herr Maier')).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeVisible() // offen
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Neuer Eintrag anlegen erscheint in der Liste', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/warteliste')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: `+ ${t.newButton}` }).click()
|
||||||
|
const form = page.getByRole('form', { name: t.formTitleNew })
|
||||||
|
await expect(form).toBeVisible()
|
||||||
|
|
||||||
|
await form.getByLabel(t.fields.contactName).fill('Neuinteressent Test')
|
||||||
|
await form.getByLabel(t.fields.wishColor).selectOption({ label: 'Schwarz' })
|
||||||
|
await form.getByLabel(t.fields.wishGender).selectOption({ label: t.wishGender.male })
|
||||||
|
await form.getByRole('button', { name: t.save }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText(de.common.saved)).toBeVisible()
|
||||||
|
await expect(page.getByText('Neuinteressent Test')).toBeVisible()
|
||||||
|
|
||||||
|
expect(mockDb).not.toBeNull()
|
||||||
|
const created = mockDb!.waitingList.find((e) => e.contactName === 'Neuinteressent Test')
|
||||||
|
expect(created).toBeTruthy()
|
||||||
|
expect(created).toMatchObject({ status: 'offen', wishColor: 'Schwarz', wishGender: 'male' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Status je Eintrag direkt umstellbar', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/warteliste')
|
||||||
|
|
||||||
|
// Status-Select des offenen Seed-Eintrags (Zoohandlung Meier) auf "erfüllt" stellen.
|
||||||
|
const statusSelect = page.getByLabel(`${t.fields.status} Zoohandlung Meier`)
|
||||||
|
await statusSelect.selectOption({ label: t.status.erfuellt })
|
||||||
|
await expect(page.getByText(de.common.saved)).toBeVisible()
|
||||||
|
|
||||||
|
expect(mockDb!.waitingList.find((e) => e.id === 'wl-seed-1')?.status).toBe('erfuellt')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Eintrag löschen entfernt ihn aus der Liste', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/warteliste')
|
||||||
|
await expect(page.getByText('Herr Maier')).toBeVisible()
|
||||||
|
|
||||||
|
acceptNextDialog(page)
|
||||||
|
// Löschen-Button im Karten-Block von Herr Maier (zweiter Eintrag).
|
||||||
|
const card = page.locator('li', { hasText: 'Herr Maier' })
|
||||||
|
await card.getByRole('button', { name: t.delete }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText(de.common.deleted)).toBeVisible()
|
||||||
|
await expect(page.getByText('Herr Maier')).toHaveCount(0)
|
||||||
|
expect(mockDb!.waitingList.find((e) => e.id === 'wl-seed-2')).toBeUndefined()
|
||||||
|
})
|
||||||
@@ -30,6 +30,7 @@ import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
|||||||
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
||||||
import AnfragenPage from './pages/AnfragenPage'
|
import AnfragenPage from './pages/AnfragenPage'
|
||||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||||
|
import WartelistePage from './pages/WartelistePage'
|
||||||
|
|
||||||
function BeckenRedirect() {
|
function BeckenRedirect() {
|
||||||
const { '*': splat } = useParams()
|
const { '*': splat } = useParams()
|
||||||
@@ -101,6 +102,8 @@ export default function App() {
|
|||||||
<Route index element={<AnfragenPage />} />
|
<Route index element={<AnfragenPage />} />
|
||||||
<Route path=":id" element={<AnfrageDetailPage />} />
|
<Route path=":id" element={<AnfrageDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
{/* WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb) */}
|
||||||
|
<Route path="warteliste" element={<WartelistePage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
56
gerbil-manager-web/src/api/waitingList.ts
Normal file
56
gerbil-manager-web/src/api/waitingList.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* WAITLIST (RennmausPro nachfrage_tb): API client for the prospective-buyer waiting list.
|
||||||
|
* Full CRUD against /waiting-list (GET list, POST, PUT /{id}, DELETE /{id}).
|
||||||
|
*
|
||||||
|
* Entries are decoupled from contacts on the backend (loose nullable contactId, no FK),
|
||||||
|
* so they survive the import re-ingest wipe — same pattern as Feedback.
|
||||||
|
*/
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
const RESOURCE = '/waiting-list'
|
||||||
|
|
||||||
|
/** Workflow status (matches the backend contract). */
|
||||||
|
export type WaitingListStatus = 'offen' | 'erfuellt' | 'storniert'
|
||||||
|
export const WAITING_LIST_STATUSES: WaitingListStatus[] = ['offen', 'erfuellt', 'storniert']
|
||||||
|
|
||||||
|
/** Wished-for gender; null = no preference. */
|
||||||
|
export type WishGender = 'male' | 'female' | null
|
||||||
|
|
||||||
|
export interface WaitingListEntry {
|
||||||
|
id: string
|
||||||
|
contactId: string | null
|
||||||
|
contactName: string | null
|
||||||
|
wishColor: string | null
|
||||||
|
wishGender: string | null
|
||||||
|
requestedAt: string | null
|
||||||
|
status: string
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for POST/PUT /waiting-list. */
|
||||||
|
export interface WaitingListInput {
|
||||||
|
contactId?: string | null
|
||||||
|
contactName?: string | null
|
||||||
|
wishColor?: string | null
|
||||||
|
wishGender?: string | null
|
||||||
|
requestedAt?: string | null
|
||||||
|
status: WaitingListStatus
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWaitingList(): Promise<WaitingListEntry[]> {
|
||||||
|
return api.get<WaitingListEntry[]>(RESOURCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWaitingListEntry(body: WaitingListInput): Promise<WaitingListEntry> {
|
||||||
|
return api.post<WaitingListEntry>(RESOURCE, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWaitingListEntry(id: string, body: WaitingListInput): Promise<WaitingListEntry> {
|
||||||
|
return api.put<WaitingListEntry>(`${RESOURCE}/${id}`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWaitingListEntry(id: string): Promise<void> {
|
||||||
|
return api.delete(`${RESOURCE}/${id}`)
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ const SECONDARY: NavItem[] = [
|
|||||||
{ to: '/reservierungen', label: de.nav.reservations, icon: '🔖' },
|
{ to: '/reservierungen', label: de.nav.reservations, icon: '🔖' },
|
||||||
// INBOX-1: Anfragen-Posteingang
|
// INBOX-1: Anfragen-Posteingang
|
||||||
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
|
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
|
||||||
|
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
|
||||||
|
{ to: '/warteliste', label: de.nav.waitingList, icon: '📝' },
|
||||||
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
||||||
// FEAT-13: Abgabeverträge + Zuchtprofil
|
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||||
|
|||||||
375
gerbil-manager-web/src/pages/WartelistePage.tsx
Normal file
375
gerbil-manager-web/src/pages/WartelistePage.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* WAITLIST (RennmausPro nachfrage_tb): Warteliste/Nachfrage.
|
||||||
|
*
|
||||||
|
* Interessenten mit Wunschkriterien (Farbschlag, Geschlecht), Anfragedatum,
|
||||||
|
* Status (offen | erfuellt | storniert) und Notiz. Anlegen/Bearbeiten über ein
|
||||||
|
* Inline-Formular; Status direkt je Eintrag umstellbar; Filter „nur offene".
|
||||||
|
* Wunsch-Farbschlag aus dem bestehenden Farbkatalog wählbar; Kontakt-Bezug als Link.
|
||||||
|
*
|
||||||
|
* Entkoppelt vom Kontakt (lose contactId ohne FK) → überlebt den Import-Re-Ingest.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
|
import { listContacts, listColorVarieties } from '../api/lookups'
|
||||||
|
import {
|
||||||
|
listWaitingList,
|
||||||
|
createWaitingListEntry,
|
||||||
|
updateWaitingListEntry,
|
||||||
|
deleteWaitingListEntry,
|
||||||
|
WAITING_LIST_STATUSES,
|
||||||
|
type WaitingListEntry,
|
||||||
|
type WaitingListInput,
|
||||||
|
type WaitingListStatus,
|
||||||
|
} from '../api/waitingList'
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
contactId: string
|
||||||
|
contactName: string
|
||||||
|
wishColor: string
|
||||||
|
wishGender: '' | 'male' | 'female'
|
||||||
|
requestedAt: string
|
||||||
|
status: WaitingListStatus
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: FormState = {
|
||||||
|
contactId: '',
|
||||||
|
contactName: '',
|
||||||
|
wishColor: '',
|
||||||
|
wishGender: '',
|
||||||
|
requestedAt: '',
|
||||||
|
status: 'offen',
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "" -> null, sonst der Wert. */
|
||||||
|
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||||
|
|
||||||
|
function isStatus(value: string): value is WaitingListStatus {
|
||||||
|
return (WAITING_LIST_STATUSES as string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WartelistePage() {
|
||||||
|
const t = de.pages.warteliste
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const entries = useApi(() => listWaitingList(), [])
|
||||||
|
const contacts = useApi(() => listContacts(), [])
|
||||||
|
const colors = useApi(() => listColorVarieties(), [])
|
||||||
|
|
||||||
|
const [onlyOpen, setOnlyOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null) // null = nicht im Formular, '' = neu
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY)
|
||||||
|
|
||||||
|
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const saveMutation = useMutation((args: { id: string | null; body: WaitingListInput }) =>
|
||||||
|
args.id ? updateWaitingListEntry(args.id, args.body) : createWaitingListEntry(args.body),
|
||||||
|
)
|
||||||
|
const deleteMutation = useMutation((id: string) => deleteWaitingListEntry(id))
|
||||||
|
|
||||||
|
const contactNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const c of contacts.data ?? []) map.set(c.id, c.name)
|
||||||
|
return map
|
||||||
|
}, [contacts.data])
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const rows = entries.data ?? []
|
||||||
|
return onlyOpen ? rows.filter((e) => e.status === 'offen') : rows
|
||||||
|
}, [entries.data, onlyOpen])
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setForm(EMPTY)
|
||||||
|
setEditingId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(e: WaitingListEntry) {
|
||||||
|
setForm({
|
||||||
|
contactId: e.contactId ?? '',
|
||||||
|
contactName: e.contactName ?? '',
|
||||||
|
wishColor: e.wishColor ?? '',
|
||||||
|
wishGender: e.wishGender === 'male' || e.wishGender === 'female' ? e.wishGender : '',
|
||||||
|
requestedAt: e.requestedAt ? e.requestedAt.slice(0, 10) : '',
|
||||||
|
status: isStatus(e.status) ? e.status : 'offen',
|
||||||
|
note: e.note ?? '',
|
||||||
|
})
|
||||||
|
setEditingId(e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBody(): WaitingListInput {
|
||||||
|
return {
|
||||||
|
contactId: nn(form.contactId),
|
||||||
|
contactName: nn(form.contactName),
|
||||||
|
wishColor: nn(form.wishColor),
|
||||||
|
wishGender: form.wishGender === '' ? null : form.wishGender,
|
||||||
|
requestedAt: form.requestedAt ? new Date(`${form.requestedAt}T00:00:00Z`).toISOString() : null,
|
||||||
|
status: form.status,
|
||||||
|
note: nn(form.note),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(ev: FormEvent) {
|
||||||
|
ev.preventDefault()
|
||||||
|
if (!form.contactId && form.contactName.trim() === '') {
|
||||||
|
toast.error(t.validationName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await saveMutation.run({ id: editingId || null, body: buildBody() })
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
closeForm()
|
||||||
|
entries.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schnell-Statuswechsel direkt in der Liste (sendet den vollständigen Eintrag mit). */
|
||||||
|
async function changeStatus(entry: WaitingListEntry, status: WaitingListStatus) {
|
||||||
|
const body: WaitingListInput = {
|
||||||
|
contactId: entry.contactId,
|
||||||
|
contactName: entry.contactName,
|
||||||
|
wishColor: entry.wishColor,
|
||||||
|
wishGender: entry.wishGender,
|
||||||
|
requestedAt: entry.requestedAt,
|
||||||
|
status,
|
||||||
|
note: entry.note,
|
||||||
|
}
|
||||||
|
const result = await saveMutation.run({ id: entry.id, body })
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(de.common.saved)
|
||||||
|
entries.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(entry: WaitingListEntry) {
|
||||||
|
if (!window.confirm(t.deleteConfirm)) return
|
||||||
|
const result = await deleteMutation.run(entry.id)
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(de.common.deleted)
|
||||||
|
entries.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = (status: string): string =>
|
||||||
|
isStatus(status) ? t.status[status] : status
|
||||||
|
|
||||||
|
const genderLabel = (g: string | null): string =>
|
||||||
|
g === 'male' ? t.wishGender.male : g === 'female' ? t.wishGender.female : t.wishGender.any
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<p className="muted">{t.subtitle}</p>
|
||||||
|
{!entries.loading && !entries.error && (
|
||||||
|
<p className="muted">
|
||||||
|
{visible.length} {t.countLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn--primary" onClick={openNew}>
|
||||||
|
+ {t.newButton}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="filters">
|
||||||
|
<label className="field field--check">
|
||||||
|
<span>{t.onlyOpen}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyOpen}
|
||||||
|
onChange={(ev) => setOnlyOpen(ev.target.checked)}
|
||||||
|
aria-label={t.onlyOpen}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingId !== null && (
|
||||||
|
<form className="form" onSubmit={onSubmit} noValidate aria-label={editingId ? t.formTitleEdit : t.formTitleNew}>
|
||||||
|
<h3>{editingId ? t.formTitleEdit : t.formTitleNew}</h3>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.contact}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.contactId}
|
||||||
|
onChange={(ev) => set('contactId', ev.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t.fields.contactNone}</option>
|
||||||
|
{(contacts.data ?? []).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.contactName}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.contactName}
|
||||||
|
onChange={(ev) => set('contactName', ev.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.wishColor}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.wishColor}
|
||||||
|
onChange={(ev) => set('wishColor', ev.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t.fields.wishColorAny}</option>
|
||||||
|
{(colors.data ?? []).map((c) => (
|
||||||
|
<option key={c.id} value={c.name}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.wishGender}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.wishGender}
|
||||||
|
onChange={(ev) => set('wishGender', ev.target.value as FormState['wishGender'])}
|
||||||
|
>
|
||||||
|
<option value="">{t.wishGender.any}</option>
|
||||||
|
<option value="female">{t.wishGender.female}</option>
|
||||||
|
<option value="male">{t.wishGender.male}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.requestedAt}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={form.requestedAt}
|
||||||
|
onChange={(ev) => set('requestedAt', ev.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.status}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(ev) => set('status', ev.target.value as WaitingListStatus)}
|
||||||
|
>
|
||||||
|
{WAITING_LIST_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t.status[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.note}</span>
|
||||||
|
<textarea value={form.note} onChange={(ev) => set('note', ev.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{saveMutation.error && <div className="alert alert--error">{saveMutation.error}</div>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn--primary" disabled={saveMutation.pending}>
|
||||||
|
{t.save}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={closeForm}>
|
||||||
|
{t.cancel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{entries.error && (
|
||||||
|
<div className="alert alert--error">
|
||||||
|
<span>{t.loadError}</span>
|
||||||
|
<button type="button" className="btn" onClick={entries.reload}>
|
||||||
|
{de.common.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!entries.loading && !entries.error && visible.length === 0 && (
|
||||||
|
<p className="muted">{onlyOpen ? t.emptyOpen : t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible.length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{visible.map((e) => {
|
||||||
|
const contactName = e.contactId ? contactNameById.get(e.contactId) : null
|
||||||
|
return (
|
||||||
|
<li key={e.id}>
|
||||||
|
<div className="gerbil-card" style={{ display: 'block' }}>
|
||||||
|
<div className="gerbil-card__name">
|
||||||
|
{e.contactId && contactName ? (
|
||||||
|
<Link to={`/kontakte/${e.contactId}`}>{contactName}</Link>
|
||||||
|
) : (
|
||||||
|
(e.contactName ?? t.noContactLink)
|
||||||
|
)}
|
||||||
|
{' — '}
|
||||||
|
<span className={`badge badge--${e.status}`}>{statusLabel(e.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="gerbil-card__meta">
|
||||||
|
{(e.wishColor ?? t.fields.wishColorAny)} · {genderLabel(e.wishGender)}
|
||||||
|
{e.requestedAt ? ` · ${e.requestedAt.slice(0, 10)}` : ''}
|
||||||
|
</div>
|
||||||
|
{e.note && <p className="muted">{e.note}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.status}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={isStatus(e.status) ? e.status : 'offen'}
|
||||||
|
onChange={(ev) => changeStatus(e, ev.target.value as WaitingListStatus)}
|
||||||
|
aria-label={`${t.fields.status} ${e.contactName ?? contactName ?? ''}`.trim()}
|
||||||
|
disabled={saveMutation.pending}
|
||||||
|
>
|
||||||
|
{WAITING_LIST_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t.status[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn" onClick={() => openEdit(e)}>
|
||||||
|
{t.edit}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => onDelete(e)}
|
||||||
|
disabled={deleteMutation.pending}
|
||||||
|
>
|
||||||
|
{t.delete}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export const de = {
|
|||||||
settings: 'Einstellungen',
|
settings: 'Einstellungen',
|
||||||
// INBOX-1 (Kelly): Anfragen-Posteingang
|
// INBOX-1 (Kelly): Anfragen-Posteingang
|
||||||
requests: 'Anfragen',
|
requests: 'Anfragen',
|
||||||
|
// WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb)
|
||||||
|
waitingList: 'Warteliste',
|
||||||
openMenu: 'Menü öffnen',
|
openMenu: 'Menü öffnen',
|
||||||
closeMenu: 'Menü schließen',
|
closeMenu: 'Menü schließen',
|
||||||
mainNavigation: 'Hauptnavigation',
|
mainNavigation: 'Hauptnavigation',
|
||||||
@@ -997,6 +999,52 @@ export const de = {
|
|||||||
errorTitle: 'Es ist ein Fehler aufgetreten',
|
errorTitle: 'Es ist ein Fehler aufgetreten',
|
||||||
restart: 'Neue Datei auswählen',
|
restart: 'Neue Datei auswählen',
|
||||||
},
|
},
|
||||||
|
// ── WAITLIST (RennmausPro nachfrage_tb): Warteliste/Nachfrage ──
|
||||||
|
warteliste: {
|
||||||
|
title: 'Warteliste',
|
||||||
|
subtitle: 'Interessenten warten auf bestimmte Tiere',
|
||||||
|
newButton: 'Neuer Eintrag',
|
||||||
|
empty: 'Keine Wartelisten-Einträge vorhanden.',
|
||||||
|
emptyOpen: 'Keine offenen Einträge.',
|
||||||
|
countLabel: 'Einträge',
|
||||||
|
onlyOpen: 'Nur offene',
|
||||||
|
loadError: 'Die Warteliste konnte nicht geladen werden.',
|
||||||
|
saveError: 'Der Eintrag konnte nicht gespeichert werden.',
|
||||||
|
deleteError: 'Der Eintrag konnte nicht gelöscht werden.',
|
||||||
|
deleteConfirm: 'Diesen Wartelisten-Eintrag wirklich löschen?',
|
||||||
|
// Status-Bezeichnungen (Backend-Werte: offen | erfuellt | storniert)
|
||||||
|
status: {
|
||||||
|
offen: 'Offen',
|
||||||
|
erfuellt: 'Erfüllt',
|
||||||
|
storniert: 'Storniert',
|
||||||
|
},
|
||||||
|
// Geschlechts-Wunsch
|
||||||
|
wishGender: {
|
||||||
|
any: 'Egal',
|
||||||
|
male: 'Männlich',
|
||||||
|
female: 'Weiblich',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
contact: 'Kontakt',
|
||||||
|
contactNone: '— kein Kontakt —',
|
||||||
|
contactName: 'Name (falls kein Kontakt)',
|
||||||
|
wishColor: 'Wunsch-Farbschlag',
|
||||||
|
wishColorAny: 'Egal',
|
||||||
|
wishGender: 'Wunsch-Geschlecht',
|
||||||
|
requestedAt: 'Angefragt am',
|
||||||
|
status: 'Status',
|
||||||
|
note: 'Notiz',
|
||||||
|
},
|
||||||
|
// Aktionen je Eintrag
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
delete: 'Löschen',
|
||||||
|
save: 'Speichern',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
formTitleNew: 'Neuer Wartelisten-Eintrag',
|
||||||
|
formTitleEdit: 'Eintrag bearbeiten',
|
||||||
|
validationName: 'Bitte einen Kontakt wählen oder einen Namen eingeben.',
|
||||||
|
noContactLink: 'Kein Kontakt verknüpft',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── HELP-1: In-App-Anleitung ──
|
// ── HELP-1: In-App-Anleitung ──
|
||||||
hilfe: {
|
hilfe: {
|
||||||
|
|||||||
Reference in New Issue
Block a user