feat(ausstellungen): Ausstellungs-/Auszeichnungsergebnisse je Tier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
211
GerbilManager.Tests/ExhibitionEndpointTests.cs
Normal file
211
GerbilManager.Tests/ExhibitionEndpointTests.cs
Normal 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 & 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Request> Requests => Set<Request>();
|
public DbSet<Request> Requests => Set<Request>();
|
||||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||||
|
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -212,6 +213,14 @@ public class ApplicationContext : DbContext
|
|||||||
e.HasIndex(f => f.CreatedAt);
|
e.HasIndex(f => f.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).
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
if (isNpgsql)
|
if (isNpgsql)
|
||||||
{
|
{
|
||||||
|
|||||||
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal file
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal 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);
|
||||||
|
}
|
||||||
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal file
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal 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 & 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1586
GerbilManagerWebAPI/Migrations/20260622201620_AddExhibitionResult.Designer.cs
generated
Normal file
1586
GerbilManagerWebAPI/Migrations/20260622201620_AddExhibitionResult.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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -796,6 +796,44 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("EnclosurePhotos");
|
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 =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
44
GerbilManagerWebAPI/Models/ExhibitionResult.cs
Normal file
44
GerbilManagerWebAPI/Models/ExhibitionResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
|
|||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
app.MapFeedbackEndpoints();
|
app.MapFeedbackEndpoints();
|
||||||
|
app.MapExhibitionEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
49
gerbil-manager-web/e2e/ausstellungen.spec.ts
Normal file
49
gerbil-manager-web/e2e/ausstellungen.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
@@ -416,6 +416,59 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
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>
|
// 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
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export interface MockDb {
|
|||||||
namesConfigured: boolean
|
namesConfigured: boolean
|
||||||
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
||||||
feedback: Record<string, unknown>[]
|
feedback: Record<string, unknown>[]
|
||||||
|
// EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST /exhibitions)
|
||||||
|
exhibitions: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -376,5 +378,6 @@ export function seedDb(): MockDb {
|
|||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
namesConfigured: true,
|
namesConfigured: true,
|
||||||
feedback: [],
|
feedback: [],
|
||||||
|
exhibitions: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
gerbil-manager-web/src/api/exhibitions.ts
Normal file
61
gerbil-manager-web/src/api/exhibitions.ts
Normal 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}`)
|
||||||
|
}
|
||||||
238
gerbil-manager-web/src/components/GerbilExhibitionsTab.tsx
Normal file
238
gerbil-manager-web/src/components/GerbilExhibitionsTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
|||||||
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||||
import type { Gender, GerbilStatus } from '../api/types'
|
import type { Gender, GerbilStatus } from '../api/types'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
|
import GerbilExhibitionsTab from '../components/GerbilExhibitionsTab'
|
||||||
import GerbilHealthTab from '../components/GerbilHealthTab'
|
import GerbilHealthTab from '../components/GerbilHealthTab'
|
||||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||||
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
||||||
@@ -20,7 +21,7 @@ import { useGerbilName } from '../components/breederSuffix'
|
|||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../components/toast'
|
||||||
import './rennmausakte.css'
|
import './rennmausakte.css'
|
||||||
|
|
||||||
type DetailTab = 'photos' | 'health' | 'weight'
|
type DetailTab = 'photos' | 'health' | 'weight' | 'exhibitions'
|
||||||
|
|
||||||
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
|
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
|
||||||
const STATUS_COLORS: Record<GerbilStatus, string> = {
|
const STATUS_COLORS: Record<GerbilStatus, string> = {
|
||||||
@@ -461,7 +462,7 @@ export default function GerbilDetailPage() {
|
|||||||
<section className="ak-card">
|
<section className="ak-card">
|
||||||
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
||||||
<div className="ak-tabs" role="tablist">
|
<div className="ak-tabs" role="tablist">
|
||||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
{(['photos', 'health', 'weight', 'exhibitions'] as const).map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -478,6 +479,12 @@ export default function GerbilDetailPage() {
|
|||||||
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
|
||||||
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
|
||||||
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
|
||||||
|
{tab === 'exhibitions' && (
|
||||||
|
<GerbilExhibitionsTab
|
||||||
|
gerbilId={g.id}
|
||||||
|
entityName={gerbilName(g) || de.pages.gerbils.nameless}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const de = {
|
|||||||
photos: 'Fotos',
|
photos: 'Fotos',
|
||||||
health: 'Gesundheit',
|
health: 'Gesundheit',
|
||||||
weight: 'Gewicht',
|
weight: 'Gewicht',
|
||||||
|
exhibitions: 'Ausstellungen',
|
||||||
},
|
},
|
||||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||||
@@ -731,6 +732,29 @@ export const de = {
|
|||||||
fileRequired: 'Bitte zuerst ein Foto auswählen.',
|
fileRequired: 'Bitte zuerst ein Foto auswählen.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── 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) ──
|
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
|
||||||
datenexport: {
|
datenexport: {
|
||||||
|
|||||||
Reference in New Issue
Block a user