diff --git a/GerbilManager.Tests/FeedbackEndpointTests.cs b/GerbilManager.Tests/FeedbackEndpointTests.cs index b5f96e0..ae29d73 100644 --- a/GerbilManager.Tests/FeedbackEndpointTests.cs +++ b/GerbilManager.Tests/FeedbackEndpointTests.cs @@ -43,11 +43,81 @@ public class FeedbackEndpointTests : IClassFixture Assert.Equal("Krümel", created.GetProperty("entityName").GetString()); Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString()); + // A freshly created ticket starts Open with no ResolvedAt. + Assert.Equal("Open", created.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind); + // GET returns it (newest first) var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("entityName").GetString() == "Krümel" - && f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."); + && f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater." + && f.GetProperty("status").GetString() == "Open"); + } + + [Fact] + public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete() + { + var client = _factory.CreateClient(); + + // Create + var create = await client.PostAsJsonAsync("/feedback", new + { + message = "Gewicht wird falsch gerundet.", + context = "gerbil-detail", + entityName = "Balu", + }); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + Assert.False(string.IsNullOrEmpty(id)); + + // List contains it + var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id); + + // Edit message + var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." }); + Assert.Equal(HttpStatusCode.OK, edit.StatusCode); + var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString()); + Assert.Equal("Open", edited.GetProperty("status").GetString()); + + // Empty message is rejected + var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = " " }); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + + // Mark resolved -> ResolvedAt is set + var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" }); + Assert.Equal(HttpStatusCode.OK, resolve.StatusCode); + var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Resolved", resolved.GetProperty("status").GetString()); + Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind); + + // Reopen -> ResolvedAt cleared + var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" }); + Assert.Equal(HttpStatusCode.OK, reopen.StatusCode); + var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Open", reopened.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind); + + // Delete -> 204, then 404 on subsequent edit/delete + var del = await client.DeleteAsync($"/feedback/{id}"); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + + var delAgain = await client.DeleteAsync($"/feedback/{id}"); + Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode); + + var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" }); + Assert.Equal(HttpStatusCode.NotFound, editGone.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($"/feedback/{missing}", new { message = "x" })).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/feedback/{missing}")).StatusCode); } [Fact] @@ -108,6 +178,8 @@ public class FeedbackEndpointTests : IClassFixture EntityName = "Papa", Url = "http://localhost/rennmaeuse/papa", CreatedAt = DateTimeOffset.UtcNow, + Status = "Resolved", + ResolvedAt = DateTimeOffset.UtcNow, }); // A contact-scoped feedback report — the ContactId is a loose (FK-free) id, // so it must survive the contact-table wipe just like gerbil/litter ids. @@ -137,6 +209,8 @@ public class FeedbackEndpointTests : IClassFixture Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated Assert.Equal(litterId, survivor.LitterId); Assert.Equal("Papa", survivor.EntityName); + Assert.Equal("Resolved", survivor.Status); // ticket status column survives the wipe + Assert.NotNull(survivor.ResolvedAt); // The contact-scoped report also survives the contacts wipe (loose ContactId). var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId); diff --git a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs index 52c6110..09a7c93 100644 --- a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -23,5 +23,12 @@ namespace GerbilManagerWebAPI.Dtos string? Url, DateTimeOffset? ClientTimestamp, string? UserAgent, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + string Status, + DateTimeOffset? ResolvedAt); + + /// FEEDBACK: payload for PUT /feedback/{id} (edit message and/or toggle status). + public record FeedbackUpdate( + string? Message, + string? Status); } diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs index adb312e..b4842bb 100644 --- a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -6,9 +6,11 @@ using Microsoft.EntityFrameworkCore; namespace GerbilManagerWebAPI.Endpoints { /// - /// FEEDBACK: the "Fehler melden" report sink. - /// POST /feedback -> persist a user bug report (with captured debug context), returns 201. - /// GET /feedback -> list reports, newest first (for later review). + /// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets"). + /// POST /feedback -> persist a user bug report (with captured debug context), returns 201. + /// GET /feedback -> list reports, newest first (for the ticket list). + /// PUT /feedback/{id} -> edit the message and/or toggle status Open/Resolved (sets/clears ResolvedAt). + /// DELETE /feedback/{id} -> remove a report. 404 on missing id. /// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so /// rows survive the import re-ingest wipe. /// @@ -37,6 +39,8 @@ namespace GerbilManagerWebAPI.Endpoints ClientTimestamp = input.ClientTimestamp, UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null, CreatedAt = DateTimeOffset.UtcNow, + Status = "Open", + ResolvedAt = null, }; db.Feedback.Add(entity); await db.SaveChangesAsync(); @@ -53,11 +57,51 @@ namespace GerbilManagerWebAPI.Endpoints .ToList()); }); + group.MapPut("/{id:guid}", async Task, NotFound, BadRequest>> ( + Guid id, FeedbackUpdate input, ApplicationContext db) => + { + var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); + if (entity is null) + return TypedResults.NotFound(); + + if (input.Message is not null) + { + if (string.IsNullOrWhiteSpace(input.Message)) + return TypedResults.BadRequest("Message darf nicht leer sein."); + entity.Message = input.Message.Trim(); + } + + if (input.Status is not null) + { + // Normalize to the two known states; resolving stamps ResolvedAt, reopening clears it. + var resolved = input.Status.Trim().Equals("Resolved", StringComparison.OrdinalIgnoreCase); + entity.Status = resolved ? "Resolved" : "Open"; + entity.ResolvedAt = resolved + ? (entity.ResolvedAt ?? DateTimeOffset.UtcNow) + : null; + } + + await db.SaveChangesAsync(); + return TypedResults.Ok(ToDto(entity)); + }); + + group.MapDelete("/{id:guid}", async Task> ( + Guid id, ApplicationContext db) => + { + var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); + if (entity is null) + return TypedResults.NotFound(); + + db.Feedback.Remove(entity); + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + return app; } private static FeedbackDto ToDto(Feedback f) => new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url, - f.ClientTimestamp, f.UserAgent, f.CreatedAt); + f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt); } } diff --git a/GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.Designer.cs new file mode 100644 index 0000000..53b7e1b --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.Designer.cs @@ -0,0 +1,1555 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260622153636_AddFeedbackStatus")] + partial class AddFeedbackStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + NameSuffix = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "REW", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg pp spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Page", null) + .WithMany("Blocks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null) + .WithMany() + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact") + .WithMany() + .HasForeignKey("AssignedContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.cs b/GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.cs new file mode 100644 index 0000000..07a7884 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622153636_AddFeedbackStatus.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddFeedbackStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ResolvedAt", + table: "Feedback", + type: "timestamp with time zone", + nullable: true); + + // Existing feedback rows default to "Open" (the lifecycle's initial state). + migrationBuilder.AddColumn( + name: "Status", + table: "Feedback", + type: "text", + nullable: false, + defaultValue: "Open"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ResolvedAt", + table: "Feedback"); + + migrationBuilder.DropColumn( + name: "Status", + table: "Feedback"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 725b04b..9823dc4 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -828,6 +828,13 @@ namespace GerbilManagerWebAPI.Migrations .IsRequired() .HasColumnType("text"); + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + b.Property("Url") .HasColumnType("text"); diff --git a/GerbilManagerWebAPI/Models/Feedback.cs b/GerbilManagerWebAPI/Models/Feedback.cs index 76f319f..aeddf3d 100644 --- a/GerbilManagerWebAPI/Models/Feedback.cs +++ b/GerbilManagerWebAPI/Models/Feedback.cs @@ -43,5 +43,14 @@ namespace GerbilManagerWebAPI.Models /// Server-side creation time. public DateTimeOffset CreatedAt { get; set; } + + /// + /// Ticket lifecycle status: "Open" | "Resolved" (default "Open"). Plain string, + /// no FK — keeps feedback decoupled and ingest-surviving like the rest of the row. + /// + public string Status { get; set; } = "Open"; + + /// When the ticket was marked resolved; null while open. + public DateTimeOffset? ResolvedAt { get; set; } } } diff --git a/gerbil-manager-web/e2e/feedback.spec.ts b/gerbil-manager-web/e2e/feedback.spec.ts index a69b9ce..942d7a7 100644 --- a/gerbil-manager-web/e2e/feedback.spec.ts +++ b/gerbil-manager-web/e2e/feedback.spec.ts @@ -25,17 +25,17 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', await expect(page.getByText(f.success)).toBeVisible() await expect(dialog).not.toBeVisible() - // Der Bericht wurde mit Debug-Kontext gespeichert. + // Der Bericht wurde mit Debug-Kontext gespeichert (zuletzt eingereichter Eintrag; + // die Mock-DB ist mit Beispiel-Tickets vorbefüllt). expect(mockDb).not.toBeNull() - const reports = mockDb!.feedback - expect(reports.length).toBe(1) - expect(reports[0]).toMatchObject({ + const report = mockDb!.feedback.find((r) => r.message === 'Der Farbschlag stimmt nicht.') + expect(report).toMatchObject({ message: 'Der Farbschlag stimmt nicht.', context: 'gerbil-detail', gerbilId: 'kruemel', }) - expect(reports[0].url).toContain('/rennmaeuse/kruemel') - expect(typeof reports[0].clientTimestamp).toBe('string') + expect(report!.url).toContain('/rennmaeuse/kruemel') + expect(typeof report!.clientTimestamp).toBe('string') }) test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => { @@ -51,9 +51,8 @@ test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wur await dialog.getByRole('button', { name: f.submit }).click() await expect(page.getByText(f.success)).toBeVisible() - const reports = mockDb!.feedback - expect(reports.length).toBe(1) - expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' }) + const report = mockDb!.feedback.find((r) => r.message === 'Die Wurfstärke ist falsch.') + expect(report).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' }) }) test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => { @@ -69,9 +68,8 @@ test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async await dialog.getByRole('button', { name: f.submit }).click() await expect(page.getByText(f.success)).toBeVisible() - const reports = mockDb!.feedback - expect(reports.length).toBe(1) - expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' }) + const report = mockDb!.feedback.find((r) => r.message === 'Die Adresse stimmt nicht.') + expect(report).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' }) }) test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => { @@ -97,9 +95,8 @@ test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden await dialog.getByRole('button', { name: f.submit }).click() await expect(page.getByText(f.success)).toBeVisible() - const reports = mockDb!.feedback - expect(reports.length).toBe(1) - expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' }) + const report = mockDb!.feedback.find((r) => r.message === 'Stammbaum-Bug.') + expect(report).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' }) }) test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => { diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 5cf130c..d189e2f 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -407,6 +407,8 @@ export async function installMockApi(page: Page): Promise { ...body, userAgent: request.headers()['user-agent'] ?? null, createdAt: new Date().toISOString(), + status: 'Open', + resolvedAt: null, } db.feedback.push(created) return json(route, 201, created) @@ -416,6 +418,32 @@ export async function installMockApi(page: Page): Promise { } return json(route, 405) } + // FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE. + const fm = path.match(/^\/feedback\/([^/]+)$/) + if (fm) { + const fid = decodeURIComponent(fm[1]) + const idx = db.feedback.findIndex((f) => f.id === fid) + if (idx < 0) return json(route, 404, { title: 'Not Found' }) + if (method === 'PUT') { + const body = request.postDataJSON() as { message?: string; status?: string } + const row = db.feedback[idx] + if (typeof body.message === 'string') { + if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.') + row.message = body.message.trim() + } + if (typeof body.status === 'string') { + const resolved = body.status.toLowerCase() === 'resolved' + row.status = resolved ? 'Resolved' : 'Open' + row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null + } + return json(route, 200, row) + } + if (method === 'DELETE') { + db.feedback.splice(idx, 1) + return json(route, 204) + } + return json(route, 405) + } // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 2c11cdd..44fa4ce 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -391,6 +391,39 @@ export function seedDb(): MockDb { ], saleAdConfigured: true, namesConfigured: true, - feedback: [], + // FEEDBACK-TICKETS: ein offenes + ein gelöstes Ticket (GET liefert neueste zuerst → + // db.feedback wird im Mock umgekehrt; das offene steht hier zuletzt = erscheint oben). + feedback: [ + { + id: 'feedback-resolved', + message: 'Der Stammbaum zeigt den falschen Vater bei Krümel.', + context: 'stammbaum', + gerbilId: 'kruemel', + litterId: null, + contactId: null, + entityName: 'Krümel', + url: 'http://localhost:5173/rennmaeuse/kruemel/stammbaum', + clientTimestamp: '2026-06-10T09:00:00Z', + userAgent: null, + createdAt: '2026-06-10T09:00:00Z', + status: 'Resolved', + resolvedAt: '2026-06-12T14:00:00Z', + }, + { + id: 'feedback-open', + message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.', + context: 'gerbil-detail', + gerbilId: 'fridolin', + litterId: null, + contactId: null, + entityName: 'Fridolin', + url: 'http://localhost:5173/rennmaeuse/fridolin', + clientTimestamp: '2026-06-15T11:30:00Z', + userAgent: null, + createdAt: '2026-06-15T11:30:00Z', + status: 'Open', + resolvedAt: null, + }, + ], } } diff --git a/gerbil-manager-web/e2e/tickets.spec.ts b/gerbil-manager-web/e2e/tickets.spec.ts new file mode 100644 index 0000000..1654bf2 --- /dev/null +++ b/gerbil-manager-web/e2e/tickets.spec.ts @@ -0,0 +1,75 @@ +/** + * FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte. + * Erreichbar über Hilfe → „Meine Tickets". Liste mit Status-Badges (Offen/Gelöst), + * gelöst markieren / wieder öffnen, Nachricht bearbeiten, löschen. + */ +import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures' + +const tt = de.feedback.tickets + +test.describe('Meine Tickets', () => { + test.beforeEach(() => skipUnlessMock()) + + test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => { + await gotoSection(page, de.nav.help) + await page.getByRole('link', { name: tt.link }).click() + + await expect(page.getByRole('heading', { name: tt.title })).toBeVisible() + + // Offenes Ticket (Badge „Offen") + dessen Nachricht. + await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible() + await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible() + + // Gelöstes Ticket (Badge mit Häkchen + „Gelöst"). + await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible() + await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible() + }) + + test('Ticket als gelöst markieren', async ({ page }) => { + await page.goto('/hilfe/tickets') + + const openCard = page + .locator('.ticket-card') + .filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' }) + await expect(openCard.getByText(tt.statusOpen, { exact: true })).toBeVisible() + + await openCard.getByRole('button', { name: tt.markResolved }).click() + + // Badge wechselt auf „Gelöst", Aktion bietet jetzt „Wieder öffnen". + // Auf das Badge (.ticket-badge) zielen — „Gelöst" steht sonst auch im Meta-Label „Gelöst am". + await expect(openCard.locator('.ticket-badge--resolved')).toBeVisible() + await expect(openCard.getByRole('button', { name: tt.reopen })).toBeVisible() + }) + + test('Nachricht eines Tickets bearbeiten', async ({ page }) => { + await page.goto('/hilfe/tickets') + + // Karte über den Datensatz-Namen (bleibt auch im Bearbeiten-Modus sichtbar) + // adressieren — die Nachricht selbst wird beim Bearbeiten durch das Textfeld ersetzt. + const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' }) + await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible() + await card.getByRole('button', { name: tt.edit }).click() + + const textarea = card.locator('textarea') + await textarea.fill('Korrigierte Beschreibung des Fehlers.') + await card.getByRole('button', { name: tt.save }).click() + + await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible() + }) + + test('Ticket löschen', async ({ page }) => { + await page.goto('/hilfe/tickets') + + const card = page + .locator('.ticket-card') + .filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }) + await expect(card).toBeVisible() + + acceptNextDialog(page) + await card.getByRole('button', { name: tt.delete }).click() + + await expect( + page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'), + ).toHaveCount(0) + }) +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 6c1af9a..82b1990 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage' import StammbaumPage from './pages/StammbaumPage' import StatistikPage from './pages/StatistikPage' import HilfePage from './pages/HilfePage' +import TicketsPage from './pages/TicketsPage' import FarbkatalogPage from './pages/FarbkatalogPage' import VertraegeListPage from './pages/VertraegeListPage' import VertragWizardPage from './pages/VertragWizardPage' @@ -70,7 +71,11 @@ export default function App() { } /> } /> } /> - } /> + + } /> + {/* FEEDBACK-TICKETS: "Meine Tickets" — verwaltet eingereichte Fehlerberichte */} + } /> + } /> {/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */} diff --git a/gerbil-manager-web/src/api/feedback.ts b/gerbil-manager-web/src/api/feedback.ts index 13b2b01..5587471 100644 --- a/gerbil-manager-web/src/api/feedback.ts +++ b/gerbil-manager-web/src/api/feedback.ts @@ -18,6 +18,9 @@ export interface FeedbackInput { clientTimestamp?: string | null } +/** Ticket lifecycle status (matches the backend Status contract). */ +export type FeedbackStatus = 'Open' | 'Resolved' + export interface Feedback { id: string message: string @@ -30,8 +33,34 @@ export interface Feedback { clientTimestamp: string | null userAgent: string | null createdAt: string + status: FeedbackStatus + resolvedAt: string | null +} + +/** Alias used by the "Meine Tickets" area for readability. */ +export type FeedbackTicket = Feedback + +/** Payload for PUT /feedback/{id}: edit the message and/or toggle the status. */ +export interface FeedbackUpdate { + message?: string + status?: FeedbackStatus } export function submitFeedback(body: FeedbackInput): Promise { return api.post(RESOURCE, body) } + +/** List all feedback tickets, newest first. */ +export function listFeedback(): Promise { + return api.get(RESOURCE) +} + +/** Edit a ticket's message and/or status. */ +export function updateFeedback(id: string, body: FeedbackUpdate): Promise { + return api.put(`${RESOURCE}/${id}`, body) +} + +/** Delete a ticket. */ +export function deleteFeedback(id: string): Promise { + return api.delete(`${RESOURCE}/${id}`) +} diff --git a/gerbil-manager-web/src/pages/HilfePage.tsx b/gerbil-manager-web/src/pages/HilfePage.tsx index eabf3ca..ae41627 100644 --- a/gerbil-manager-web/src/pages/HilfePage.tsx +++ b/gerbil-manager-web/src/pages/HilfePage.tsx @@ -3,6 +3,7 @@ * Accordion via
/ — kein JS-State nötig, barrierefrei, mobiltauglich. * Texte referenzieren nur Bezeichnungen aus de.ts (bleiben bei Umbenennung korrekt). */ +import { Link } from 'react-router-dom' import { de } from '../strings/de' import './hilfe.css' @@ -218,6 +219,12 @@ export default function HilfePage() {

{t.title}

{t.subtitle}

+ {/* FEEDBACK-TICKETS: Verwaltung der eingereichten Fehlerberichte */} + + {de.feedback.tickets.link} + + +
{sections.map((s) => (
diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx new file mode 100644 index 0000000..972bfdb --- /dev/null +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -0,0 +1,204 @@ +/** + * FEEDBACK-TICKETS: "Meine Tickets" — verwaltet die eingereichten "Fehler melden"-Berichte. + * + * Erreichbar über die Hilfe-Seite (/hilfe → „Meine Tickets") unter /hilfe/tickets. + * Listet alle Tickets (neueste zuerst, GET /feedback) mit Status-Badge (Offen/Gelöst). + * Pro Ticket: als gelöst markieren / wieder öffnen, Beschreibung bearbeiten (inline), + * löschen (mit Bestätigung). Erfolg/Fehler über das bestehende Toast-System. + */ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { de } from '../strings/de' +import { ApiError } from '../api/client' +import { + listFeedback, + updateFeedback, + deleteFeedback, + type FeedbackTicket, +} from '../api/feedback' +import { useApi } from '../hooks/useApi' +import { useToast } from '../components/toast' +import './tickets.css' + +const t = de.feedback.tickets +const ctx = de.feedback.contexts +const nav = de.nav + +/** Kontext-Label (Ansicht); fällt für unbekannte Werte auf den Rohwert zurück. */ +function contextLabel(context: string): string { + return (ctx as Record)[context] ?? context +} + +function formatDate(iso: string | null): string { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '—' + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +export default function TicketsPage() { + const toast = useToast() + const tickets = useApi(() => listFeedback(), []) + + async function handleToggleStatus(ticket: FeedbackTicket) { + const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved' + try { + await updateFeedback(ticket.id, { status: next }) + tickets.reload() + toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast) + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.updateError) + } + } + + async function handleSaveMessage(ticket: FeedbackTicket, message: string) { + await updateFeedback(ticket.id, { message }) + tickets.reload() + toast.success(t.saved) + } + + async function handleDelete(ticket: FeedbackTicket) { + if (!window.confirm(t.confirmDelete)) return + try { + await deleteFeedback(ticket.id) + tickets.reload() + toast.success(t.deletedToast) + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.deleteError) + } + } + + const rows = tickets.data + + return ( +
+ +

{t.title}

+

{t.subtitle}

+ + {tickets.error &&

{t.loadError}

} + + {rows === null ? null : rows.length === 0 && !tickets.error ? ( +

{t.empty}

+ ) : ( +
    + {rows.map((ticket) => ( + handleToggleStatus(ticket)} + onSaveMessage={(message) => handleSaveMessage(ticket, message)} + onDelete={() => handleDelete(ticket)} + /> + ))} +
+ )} +
+ ) +} + +interface TicketCardProps { + ticket: FeedbackTicket + onToggleStatus: () => void + onSaveMessage: (message: string) => Promise + onDelete: () => void +} + +function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketCardProps) { + const toast = useToast() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(ticket.message) + const [saving, setSaving] = useState(false) + const resolved = ticket.status === 'Resolved' + + function startEdit() { + setDraft(ticket.message) + setEditing(true) + } + + async function save() { + const trimmed = draft.trim() + if (!trimmed) { + toast.error(t.emptyMessage) + return + } + setSaving(true) + try { + await onSaveMessage(trimmed) + setEditing(false) + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.updateError) + } finally { + setSaving(false) + } + } + + return ( +
  • +
    + + {resolved ? `✓ ${t.statusResolved}` : t.statusOpen} + + {contextLabel(ticket.context)} + {ticket.entityName && {ticket.entityName}} +
    + + {editing ? ( +
    + +