Merge branch 'worktree-agent-ae59040b377a324cb'

# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/pages/GerbilDetailPage.tsx
#	gerbil-manager-web/src/strings/de.ts
This commit is contained in:
2026-06-22 23:10:18 +02:00
16 changed files with 2520 additions and 2 deletions

View File

@@ -0,0 +1,211 @@
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>
/// EXHIBITION: show/exhibition results &amp; awards per animal (/exhibitions).
/// - POST creates a row; GET (optionally filtered by gerbilId) returns it, newest first.
/// - PUT edits, DELETE removes; unknown ids return 404; an empty EventName is rejected (400).
/// - CRITICAL: exhibition rows survive the import re-ingest wipe (loose, FK-free GerbilId).
/// </summary>
public class ExhibitionEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public ExhibitionEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_creates_and_get_filters_by_gerbilId()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/exhibitions", new
{
gerbilId,
entityName = "Krümel",
eventName = "Nationale Rennmausschau 2026",
date = "2026-03-15T00:00:00Z",
placement = "1. Platz",
award = "Best in Show",
note = "Sehr ausgeglichenes Tier.",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
var id = created.GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(id));
Assert.Equal("Nationale Rennmausschau 2026", created.GetProperty("eventName").GetString());
Assert.Equal("1. Platz", created.GetProperty("placement").GetString());
Assert.Equal("Best in Show", created.GetProperty("award").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// GET filtered to this gerbil returns the row.
var listed = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={gerbilId}")).RootElement;
Assert.Contains(listed.EnumerateArray(),
x => x.GetProperty("id").GetString() == id
&& x.GetProperty("eventName").GetString() == "Nationale Rennmausschau 2026");
// GET filtered to a DIFFERENT gerbil does not.
var other = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={Guid.NewGuid()}")).RootElement;
Assert.DoesNotContain(other.EnumerateArray(), x => x.GetProperty("id").GetString() == id);
}
[Fact]
public async Task Crud_lifecycle_edit_and_delete()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/exhibitions", new
{
eventName = "Lokalschau",
entityName = "Balu",
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Edit: change placement + add a date.
var edit = await client.PutAsJsonAsync($"/exhibitions/{id}", new
{
placement = "2. Platz",
date = "2025-11-01T00:00:00Z",
});
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("2. Platz", edited.GetProperty("placement").GetString());
Assert.Equal("Lokalschau", edited.GetProperty("eventName").GetString());
// Delete -> 204, then 404 on subsequent edit/delete.
var del = await client.DeleteAsync($"/exhibitions/{id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{id}", new { eventName = "x" })).StatusCode);
}
[Fact]
public async Task Post_rejects_empty_event_name()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/exhibitions", new { eventName = " " });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Put_and_delete_unknown_id_return_404()
{
var client = _factory.CreateClient();
var missing = Guid.NewGuid();
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{missing}", new { eventName = "x" })).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{missing}")).StatusCode);
}
[Fact]
public async Task Exhibition_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "exhibition-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("exhibition-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// An exhibition result referencing the gerbil that the wipe will delete/recreate.
var exhId = Guid.NewGuid();
db.ExhibitionResults.Add(new ExhibitionResult
{
Id = exhId,
GerbilId = fatherId,
EntityName = "Papa",
EventName = "Nationale Rennmausschau 2026",
Date = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc),
Placement = "1. Platz",
Award = "Best in Show",
Note = "Tolles Tier.",
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);
// Gerbils/litters/contacts were wiped & re-created, but the exhibition row is untouched.
var survivor = await db.ExhibitionResults.SingleAsync(x => x.Id == exhId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal("Papa", survivor.EntityName);
Assert.Equal("Nationale Rennmausschau 2026", survivor.EventName);
Assert.Equal("1. Platz", survivor.Placement);
Assert.Equal("Best in Show", survivor.Award);
Assert.Equal(1, await db.ExhibitionResults.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,
};
}

View File

@@ -29,6 +29,7 @@ public class ApplicationContext : DbContext
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
// 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.
@@ -257,6 +258,14 @@ public class ApplicationContext : DbContext
e.HasIndex(r => r.CreatedAt);
});
// EXHIBITION: deliberately relationship-free (same pattern as Feedback). GerbilId is a
// plain nullable Guid column (no navigation property → EF creates NO foreign key), so the
// import re-ingest wipe of Gerbils never cascades into or breaks exhibition rows.
modelBuilder.Entity<ExhibitionResult>(e =>
{
e.HasIndex(x => x.GerbilId);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{

View File

@@ -0,0 +1,34 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>EXHIBITION: payload for POST /exhibitions.</summary>
public record ExhibitionResultInput(
Guid? GerbilId,
string? EntityName,
string EventName,
DateTime? Date,
string? Placement,
string? Award,
string? Note);
/// <summary>EXHIBITION: payload for PUT /exhibitions/{id} (all fields optional/partial).</summary>
public record ExhibitionResultUpdate(
Guid? GerbilId,
string? EntityName,
string? EventName,
DateTime? Date,
string? Placement,
string? Award,
string? Note);
/// <summary>EXHIBITION: response DTO for a stored exhibition result.</summary>
public record ExhibitionResultDto(
Guid Id,
Guid? GerbilId,
string? EntityName,
string EventName,
DateTime? Date,
string? Placement,
string? Award,
string? Note,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,114 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// EXHIBITION: show/exhibition results &amp; awards per animal (RennmausPro `ausz_tb`).
/// GET /exhibitions[?gerbilId=…] -> list (optionally filtered to one animal), newest first.
/// POST /exhibitions -> create a result, returns 201.
/// PUT /exhibitions/{id} -> edit a result (partial), 404 on missing id.
/// DELETE /exhibitions/{id} -> remove a result, 404 on missing id.
/// Decoupled from gerbils (loose nullable GerbilId, no FK) so rows survive the import re-ingest wipe.
/// </summary>
public static class ExhibitionEndpoints
{
public static IEndpointRouteBuilder MapExhibitionEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/exhibitions").WithTags("Exhibitions");
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
{
// Filter in the query; order in memory (SQLite test host cannot ORDER BY a
// DateTimeOffset column — mirrors FeedbackEndpoints).
var query = db.ExhibitionResults.AsNoTracking();
if (gerbilId is { } gid)
query = query.Where(x => x.GerbilId == gid);
var rows = await query.ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(x => x.Date ?? DateTime.MinValue)
.ThenByDescending(x => x.CreatedAt)
.Select(ToDto)
.ToList());
});
group.MapGet("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound>> (
Guid id, ApplicationContext db) =>
{
var r = await db.ExhibitionResults.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
group.MapPost("/", async Task<Results<Created<ExhibitionResultDto>, BadRequest<string>>> (
ExhibitionResultInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.EventName))
return TypedResults.BadRequest("EventName darf nicht leer sein.");
var entity = new ExhibitionResult
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
EntityName = Trim(input.EntityName),
EventName = input.EventName.Trim(),
Date = input.Date,
Placement = Trim(input.Placement),
Award = Trim(input.Award),
Note = Trim(input.Note),
CreatedAt = DateTimeOffset.UtcNow,
};
db.ExhibitionResults.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/exhibitions/{entity.Id}", ToDto(entity));
});
group.MapPut("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound, BadRequest<string>>> (
Guid id, ExhibitionResultUpdate input, ApplicationContext db) =>
{
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
if (entity is null)
return TypedResults.NotFound();
if (input.EventName is not null)
{
if (string.IsNullOrWhiteSpace(input.EventName))
return TypedResults.BadRequest("EventName darf nicht leer sein.");
entity.EventName = input.EventName.Trim();
}
if (input.GerbilId is not null) entity.GerbilId = input.GerbilId;
if (input.EntityName is not null) entity.EntityName = Trim(input.EntityName);
if (input.Date is not null) entity.Date = input.Date;
if (input.Placement is not null) entity.Placement = Trim(input.Placement);
if (input.Award is not null) entity.Award = Trim(input.Award);
if (input.Note is not null) 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.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
if (entity is null)
return TypedResults.NotFound();
db.ExhibitionResults.Remove(entity);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
// Empty/whitespace optional strings normalise to null.
private static string? Trim(string? s) =>
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static ExhibitionResultDto ToDto(ExhibitionResult r) =>
new(r.Id, r.GerbilId, r.EntityName, r.EventName, r.Date,
r.Placement, r.Award, r.Note, r.CreatedAt);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddExhibitionResult : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExhibitionResults",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
EntityName = table.Column<string>(type: "text", nullable: true),
EventName = table.Column<string>(type: "text", nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Placement = table.Column<string>(type: "text", nullable: true),
Award = 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_ExhibitionResults", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExhibitionResults_GerbilId",
table: "ExhibitionResults",
column: "GerbilId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExhibitionResults");
}
}
}

View File

@@ -840,6 +840,44 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("EnclosurePhotos");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.ExhibitionResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Award")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
b.Property<string>("EventName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<string>("Placement")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("ExhibitionResults");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// EXHIBITION: a show/exhibition result or award for an animal (RennmausPro `ausz_tb` +
/// `_AUSZ` flag — present in RPRO3 but unused by this breeder; implemented fully anyway).
///
/// Deliberately decoupled from the rest of the model, exactly like <see cref="Feedback"/>:
/// GerbilId is a plain nullable Guid column (NOT an enforced foreign key, no navigation
/// property), so the import re-ingest wipe (IngestResolvedService) can delete/recreate
/// gerbils without deleting or breaking exhibition rows. Captured EntityName keeps the
/// record human-readable even after the referenced animal is gone.
/// </summary>
public class ExhibitionResult
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the gerbil this result belongs to, if any.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Captured name of the referenced animal (survives an ingest wipe).</summary>
public string? EntityName { get; set; }
/// <summary>Name of the show/event (Veranstaltung), e.g. "Nationale Rennmausschau 2026".</summary>
public required string EventName { get; set; }
/// <summary>Date of the event, if known.</summary>
public DateTime? Date { get; set; }
/// <summary>Placement / ranking, e.g. "1. Platz", "BOB".</summary>
public string? Placement { get; set; }
/// <summary>Award / title (Auszeichnung), e.g. "Best in Show", "V1".</summary>
public string? Award { get; set; }
/// <summary>Free-text note (Bewertung / Bemerkung).</summary>
public string? Note { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -131,6 +131,7 @@ app.MapAcquisitionEndpoints();
app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();
app.MapReturnRecordEndpoints();
app.MapExhibitionEndpoints();
app.Run();

View File

@@ -0,0 +1,49 @@
/** EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier — Tab in der Rennmausakte. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.tierTabs.exhibitions
const ta = de.pages.tierTabs.actions
const tabs = de.pages.gerbils.detail.tabs
test('Rennmausakte: Ausstellungsergebnis anlegen, sehen und löschen', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Auf den "Ausstellungen"-Tab wechseln.
await page.getByRole('tab', { name: tabs.exhibitions }).click()
// Leerzustand sichtbar.
await expect(page.getByText(t.empty)).toBeVisible()
// Neues Ergebnis erfassen.
await page.getByRole('button', { name: new RegExp(t.newButton) }).click()
await page.getByLabel(new RegExp(t.fields.eventName)).fill('Nationale Rennmausschau 2026')
await page.getByLabel(t.fields.placement).fill('1. Platz')
await page.getByLabel(t.fields.award).fill('Best in Show')
await page.getByRole('button', { name: ta.save, exact: true }).click()
// Ergebnis erscheint in der Liste.
await expect(page.getByText('Nationale Rennmausschau 2026')).toBeVisible()
await expect(page.getByText('1. Platz')).toBeVisible()
// Wurde im Mock mit Tier-Bezug gespeichert.
expect(mockDb).not.toBeNull()
const row = mockDb!.exhibitions.find((r) => r.eventName === 'Nationale Rennmausschau 2026')
expect(row).toMatchObject({ gerbilId: 'kruemel', placement: '1. Platz', award: 'Best in Show' })
// Löschen (window.confirm automatisch annehmen).
page.once('dialog', (d) => void d.accept())
await page.getByRole('button', { name: ta.delete }).click()
await expect(page.getByText(t.empty)).toBeVisible()
})
test('Rennmausakte: leere Veranstaltung wird abgelehnt (Validierung)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
await page.getByRole('tab', { name: tabs.exhibitions }).click()
await page.getByRole('button', { name: new RegExp(t.newButton) }).click()
// Ohne Veranstaltung speichern → Validierungsfehler, kein Eintrag.
await page.getByRole('button', { name: ta.save, exact: true }).click()
await expect(page.getByText(t.validation.eventNameRequired)).toBeVisible()
})

View File

@@ -742,6 +742,59 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST/GET ?gerbilId=, PUT, DELETE).
// Plain-Liste (kein Gridify), gefiltert per ?gerbilId, neueste zuerst.
if (path === '/exhibitions') {
if (method === 'GET') {
const gerbilId = url.searchParams.get('gerbilId')
const rows = (db.exhibitions as Row[]).filter(
(r) => !gerbilId || r.gerbilId === gerbilId,
)
const sorted = [...rows].sort((a, b) =>
String(b.date ?? '').localeCompare(String(a.date ?? '')),
)
return json(route, 200, sorted)
}
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!String(body.eventName ?? '').trim())
return json(route, 400, 'EventName darf nicht leer sein.')
const created = {
id: newId('exhibition'),
gerbilId: body.gerbilId ?? null,
entityName: body.entityName ?? null,
eventName: String(body.eventName).trim(),
date: body.date ?? null,
placement: body.placement ?? null,
award: body.award ?? null,
note: body.note ?? null,
createdAt: new Date().toISOString(),
}
;(db.exhibitions as Row[]).push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const em = path.match(/^\/exhibitions\/([^/]+)$/)
if (em) {
const eid = decodeURIComponent(em[1])
const idx = (db.exhibitions as Row[]).findIndex((r) => r.id === eid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'GET') return json(route, 200, db.exhibitions[idx])
if (method === 'PUT') {
const body = request.postDataJSON() as Row
if ('eventName' in body && !String(body.eventName ?? '').trim())
return json(route, 400, 'EventName darf nicht leer sein.')
Object.assign(db.exhibitions[idx], body)
return json(route, 200, db.exhibitions[idx])
}
if (method === 'DELETE') {
;(db.exhibitions as Row[]).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

View File

@@ -93,6 +93,8 @@ export interface MockDb {
waitingList: Record<string, unknown>[]
// RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns)
returns: Record<string, unknown>[]
// EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST /exhibitions)
exhibitions: Record<string, unknown>[]
}
function gerbil(
@@ -620,5 +622,6 @@ export function seedDb(): MockDb {
},
],
returns: [],
exhibitions: [],
}
}

View File

@@ -0,0 +1,61 @@
/**
* EXHIBITION: typed API client for show/exhibition results & awards per animal
* (RennmausPro `ausz_tb`). Backend: GET /exhibitions[?gerbilId=], POST, PUT, DELETE.
* Decoupled from gerbils (loose nullable gerbilId, no FK) — rows survive the import wipe.
*/
import { api } from './client'
import type { DateOnlyString } from './types'
const RESOURCE = '/exhibitions'
export interface ExhibitionResult {
id: string
gerbilId: string | null
entityName: string | null
eventName: string
/** ISO date-time string (backend DateTime?), or null. */
date: DateOnlyString | string | null
placement: string | null
award: string | null
note: string | null
createdAt: string
}
/** Payload for POST /exhibitions. */
export interface CreateExhibitionResult {
gerbilId?: string | null
entityName?: string | null
eventName: string
date?: string | null
placement?: string | null
award?: string | null
note?: string | null
}
/** Payload for PUT /exhibitions/{id} (partial). */
export type UpdateExhibitionResult = Partial<CreateExhibitionResult>
/** Alle Ergebnisse eines Tieres (Backend sortiert neueste zuerst). */
export function listExhibitionResults(gerbilId: string): Promise<ExhibitionResult[]> {
return api.get<ExhibitionResult[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
}
/** Alle Ergebnisse (ungefiltert), für eine künftige Gesamtübersicht. */
export function listAllExhibitionResults(): Promise<ExhibitionResult[]> {
return api.get<ExhibitionResult[]>(RESOURCE)
}
export function createExhibitionResult(body: CreateExhibitionResult): Promise<ExhibitionResult> {
return api.post<ExhibitionResult>(RESOURCE, body)
}
export function updateExhibitionResult(
id: string,
body: UpdateExhibitionResult,
): Promise<ExhibitionResult> {
return api.put<ExhibitionResult>(`${RESOURCE}/${id}`, body)
}
export function deleteExhibitionResult(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -0,0 +1,238 @@
/**
* EXHIBITION: Ausstellungen-Tab — Show-/Ausstellungsergebnisse & Auszeichnungen
* eines Tieres (RennmausPro `ausz_tb`). Liste (neueste zuerst) + Inline-Formular
* zum Anlegen/Bearbeiten, Löschen mit Bestätigung. Deutsch durchgehend (de.ts).
*
* Eigenständige Komponente mit nur EINER Einbindungs-Zeile in GerbilDetailPage
* (entkoppelt: loses gerbilId ohne FK, übersteht den Import-Wipe).
*/
import { useState, type FormEvent } from 'react'
import { de } from '../strings/de'
import {
createExhibitionResult,
deleteExhibitionResult,
listExhibitionResults,
updateExhibitionResult,
type CreateExhibitionResult,
type ExhibitionResult,
} from '../api/exhibitions'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate } from '../format/labels'
interface FormState {
eventName: string
date: string
placement: string
award: string
note: string
}
const emptyForm = (): FormState => ({
eventName: '',
date: '',
placement: '',
award: '',
note: '',
})
/** A stored date-time → the date input's "yyyy-MM-dd" value (empty if none). */
function toDateInput(value: string | null): string {
if (!value) return ''
return value.slice(0, 10)
}
export default function GerbilExhibitionsTab({
gerbilId,
entityName,
}: {
gerbilId: string
entityName: string
}) {
const t = de.pages.tierTabs.exhibitions
const ta = de.pages.tierTabs.actions
const records = useApi(() => listExhibitionResults(gerbilId), [gerbilId])
// editing: null = kein Formular, 'new' = anlegen, sonst Record-Id (bearbeiten)
const [editing, setEditing] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const save = useMutation((body: CreateExhibitionResult) =>
editing && editing !== 'new'
? updateExhibitionResult(editing, body)
: createExhibitionResult(body),
)
const removal = useMutation((recordId: string) => deleteExhibitionResult(recordId))
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
function openCreate() {
setForm(emptyForm())
setErrors({})
setEditing('new')
}
function openEdit(r: ExhibitionResult) {
setForm({
eventName: r.eventName,
date: toDateInput(r.date as string | null),
placement: r.placement ?? '',
award: r.award ?? '',
note: r.note ?? '',
})
setErrors({})
setEditing(r.id)
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
const next: Partial<Record<keyof FormState, string>> = {}
if (form.eventName.trim() === '') next.eventName = t.validation.eventNameRequired
setErrors(next)
if (Object.keys(next).length > 0) return
const trimmed = (s: string) => (s.trim() === '' ? null : s.trim())
await save.run({
gerbilId,
entityName,
eventName: form.eventName.trim(),
// date input gives "yyyy-MM-dd"; send as ISO so the backend DateTime? parses it.
date: form.date ? `${form.date}T00:00:00Z` : null,
placement: trimmed(form.placement),
award: trimmed(form.award),
note: trimmed(form.note),
})
setEditing(null)
records.reload()
}
async function onDelete(recordId: string) {
if (!window.confirm(t.deleteConfirm)) return
await removal.run(recordId)
records.reload()
}
return (
<div>
{editing === null && (
<button type="button" className="btn btn--primary" onClick={openCreate}>
+ {t.newButton}
</button>
)}
{editing !== null && (
<form className="form" onSubmit={onSubmit} noValidate>
<h4>{editing === 'new' ? t.createTitle : t.editTitle}</h4>
<label className="field">
<span>{t.fields.eventName} *</span>
<input
className="input"
value={form.eventName}
placeholder={t.placeholders.eventName}
onChange={(e) => set('eventName', e.target.value)}
aria-invalid={Boolean(errors.eventName)}
/>
{errors.eventName && <small className="error-text">{errors.eventName}</small>}
</label>
<label className="field">
<span>{t.fields.date}</span>
<input
type="date"
className="input"
value={form.date}
onChange={(e) => set('date', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.placement}</span>
<input
className="input"
value={form.placement}
placeholder={t.placeholders.placement}
onChange={(e) => set('placement', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.award}</span>
<input
className="input"
value={form.award}
placeholder={t.placeholders.award}
onChange={(e) => set('award', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.note}</span>
<textarea value={form.note} onChange={(e) => set('note', e.target.value)} />
</label>
{save.error && <div className="alert alert--error">{save.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={save.pending}>
{save.pending ? ta.saving : ta.save}
</button>
<button type="button" className="btn" onClick={() => setEditing(null)}>
{ta.cancel}
</button>
</div>
</form>
)}
{records.loading && <p className="muted">{de.common.loading}</p>}
{records.error && (
<div className="alert alert--error">
<span>{records.error}</span>
<button type="button" className="btn" onClick={records.reload}>
{de.common.retry}
</button>
</div>
)}
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{!records.loading && !records.error && (records.data ?? []).length === 0 && (
<p className="muted">{t.empty}</p>
)}
{(records.data ?? []).length > 0 && (
<ul className="card-list" style={{ marginTop: '1rem' }}>
{(records.data ?? []).map((r) => (
<li key={r.id} className="record-card">
<div className="record-card__head">
<span className="record-card__date">
{r.date ? formatDate(toDateInput(r.date as string | null)) : '—'}
</span>
{r.placement && <span className="badge">{r.placement}</span>}
{r.award && <span className="badge badge--active">{r.award}</span>}
</div>
<p className="record-card__body">
<strong>{r.eventName}</strong>
</p>
{r.note && <p className="muted">{r.note}</p>}
<div className="record-card__actions">
<button type="button" className="btn" onClick={() => openEdit(r)}>
{ta.edit}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(r.id)}
disabled={removal.pending}
>
{ta.delete}
</button>
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -11,6 +11,7 @@ import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '..
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
import GerbilExhibitionsTab from '../components/GerbilExhibitionsTab'
import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
@@ -21,7 +22,7 @@ import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast'
import './rennmausakte.css'
type DetailTab = 'photos' | 'health' | 'weight'
type DetailTab = 'photos' | 'health' | 'weight' | 'exhibitions'
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
const STATUS_COLORS: Record<GerbilStatus, string> = {
@@ -476,7 +477,7 @@ export default function GerbilDetailPage() {
<section className="ak-card">
<h2 className="visually-hidden">{t.detail.moreData}</h2>
<div className="ak-tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => (
{(['photos', 'health', 'weight', 'exhibitions'] as const).map((key) => (
<button
key={key}
type="button"
@@ -493,6 +494,12 @@ export default function GerbilDetailPage() {
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
{tab === 'exhibitions' && (
<GerbilExhibitionsTab
gerbilId={g.id}
entityName={gerbilName(g) || de.pages.gerbils.nameless}
/>
)}
</div>
</section>
</div>

View File

@@ -119,6 +119,7 @@ export const de = {
photos: 'Fotos',
health: 'Gesundheit',
weight: 'Gewicht',
exhibitions: 'Ausstellungen',
},
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.',
@@ -853,6 +854,29 @@ export const de = {
priceRange: 'Bitte einen plausiblen Preis (0 bis 9999 €) angeben.',
},
},
// ── Ausstellungen/Auszeichnungen je Tier (RennmausPro ausz_tb) ──
exhibitions: {
empty: 'Noch keine Ausstellungsergebnisse erfasst.',
newButton: 'Neues Ergebnis',
createTitle: 'Neues Ausstellungsergebnis',
editTitle: 'Ausstellungsergebnis bearbeiten',
fields: {
eventName: 'Veranstaltung',
date: 'Datum',
placement: 'Platzierung',
award: 'Auszeichnung',
note: 'Notiz',
},
placeholders: {
eventName: 'z. B. Nationale Rennmausschau 2026',
placement: 'z. B. 1. Platz',
award: 'z. B. Best in Show',
},
validation: {
eventNameRequired: 'Bitte eine Veranstaltung angeben.',
},
deleteConfirm: 'Dieses Ausstellungsergebnis wirklich löschen?',
},
},
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
datenexport: {