From dcf0e40f9bb72d137d5c644f95424094315d9087 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 22:38:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20R=C3=BCckfragen-Dialog,=20Verl?= =?UTF-8?q?inkungen,=20Entwurfssicherung=20+=20PWA-Vorbereitung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feedback: fixNote (Changelog), agentContext (intern), thread (Q&A-Verlauf); 3 gefilterte Ansichten (Rückfragen/Offen/Geschlossen) - Tickets-Text: klickbare Tier-Links (ID→Name), Ticket→Ticket-Links (?focus=), Markdown-Links; Antwort-Entwurf pro Ticket in localStorage - "Fehler melden": Entwurf übersteht App-Wechsel/Schließen (ein gemeinsamer Entwurf) - entityName als Hyperlink zum referenzierten Datensatz Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + GerbilManager.Tests/FeedbackEndpointTests.cs | 98 + GerbilManagerWebAPI/Dtos/FeedbackDtos.cs | 24 +- .../Endpoints/FeedbackEndpoints.cs | 86 +- ...ddFeedbackFixNoteContextThread.Designer.cs | 1573 +++++++++++++++++ ...2192338_AddFeedbackFixNoteContextThread.cs | 48 + .../ApplicationContextModelSnapshot.cs | 9 + GerbilManagerWebAPI/Models/Feedback.cs | 23 + HUMANQUESTION.md.migrated | 105 -- gerbil-manager-web/e2e/feedback.spec.ts | 21 + gerbil-manager-web/e2e/mock-api.ts | 38 +- gerbil-manager-web/e2e/mock-data.ts | 16 + gerbil-manager-web/e2e/tickets.spec.ts | 104 +- gerbil-manager-web/src/api/feedback.ts | 22 + .../src/components/ReportErrorDialog.tsx | 29 +- gerbil-manager-web/src/pages/TicketsPage.tsx | 347 +++- gerbil-manager-web/src/pages/tickets.css | 175 ++ gerbil-manager-web/src/strings/de.ts | 25 + 18 files changed, 2592 insertions(+), 154 deletions(-) create mode 100644 GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.cs delete mode 100644 HUMANQUESTION.md.migrated diff --git a/.gitignore b/.gitignore index 537608d..6bc5f49 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ GerbilManagerWebAPI/.data-protection-keys/ # node_modules — never commit, installed locally per npm install node_modules/ + +# Runtime: gespeicherte Vertrags-Dateien +GerbilManagerWebAPI/contract-storage/ diff --git a/GerbilManager.Tests/FeedbackEndpointTests.cs b/GerbilManager.Tests/FeedbackEndpointTests.cs index 0b0761a..5bce2a0 100644 --- a/GerbilManager.Tests/FeedbackEndpointTests.cs +++ b/GerbilManager.Tests/FeedbackEndpointTests.cs @@ -159,6 +159,97 @@ public class FeedbackEndpointTests : IClassFixture Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString()); } + [Fact] + public async Task Put_resolve_with_fixNote_stores_breeder_changelog() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // Resolve the ticket together with a breeder-friendly fix note (changelog). + var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new + { + status = "Resolved", + fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.", + }); + Assert.Equal(HttpStatusCode.OK, resolve.StatusCode); + var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Resolved", dto.GetProperty("status").GetString()); + Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.", + dto.GetProperty("fixNote").GetString()); + Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind); + + // The fix note survives a re-fetch via GET. + var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + Assert.Contains(listed.EnumerateArray(), + f => f.GetProperty("id").GetString() == id + && f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch")); + } + + [Fact] + public async Task Put_agentContext_round_trips_without_changing_status() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // Setting only the internal agent context must NOT change the lifecycle status. + var put = await client.PutAsJsonAsync($"/feedback/{id}", new + { + agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}", + }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Open", dto.GetProperty("status").GetString()); + Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}", + dto.GetProperty("agentContext").GetString()); + + // It is returned by GET as well (for tooling) but the UI never renders it. + var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + Assert.Contains(listed.EnumerateArray(), + f => f.GetProperty("id").GetString() == id + && f.GetProperty("agentContext").GetString()!.Contains("rootCause")); + } + + [Fact] + public async Task New_question_pushes_prior_QandA_into_thread() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // Round 1: maintainer asks, breeder answers. + await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" }); + await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." }); + + // Round 2: a NEW question is asked while the prior Q&A exists. + var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" }); + Assert.Equal(HttpStatusCode.OK, ask2.StatusCode); + var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement; + + // Current open exchange = the new question, fresh (no answer yet), status NeedsInfo. + Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString()); + Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString()); + Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind); + Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind); + + // The prior round was archived into the thread (oldest first): maintainer Q then breeder A. + var thread = dto.GetProperty("thread"); + Assert.Equal(JsonValueKind.Array, thread.ValueKind); + Assert.Equal(2, thread.GetArrayLength()); + Assert.Equal("maintainer", thread[0].GetProperty("role").GetString()); + Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString()); + Assert.Equal("breeder", thread[1].GetProperty("role").GetString()); + Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString()); + + // Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds). + await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." }); + var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" }); + var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength()); + Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString()); + } + [Fact] public async Task Put_and_delete_unknown_id_return_404() { @@ -231,6 +322,9 @@ public class FeedbackEndpointTests : IClassFixture Question = "Welches Datum stimmt?", Answer = "Der 2. Juni.", AnsweredAt = DateTimeOffset.UtcNow, + FixNote = "Das Geburtsdatum von Papa wurde korrigiert.", + AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}", + Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]", }); // 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. @@ -265,6 +359,10 @@ public class FeedbackEndpointTests : IClassFixture Assert.Equal("Welches Datum stimmt?", survivor.Question); Assert.Equal("Der 2. Juni.", survivor.Answer); Assert.NotNull(survivor.AnsweredAt); + // The new fix-note / agent-context / thread columns survive the wipe too. + Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote); + Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext); + Assert.Contains("Erste Frage?", survivor.Thread); // 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 bfa302d..dc13dd8 100644 --- a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -11,6 +11,15 @@ namespace GerbilManagerWebAPI.Dtos string? Url, DateTimeOffset? ClientTimestamp); + /// + /// FEEDBACK: one past Q&A turn in the ticket's thread/history. + /// Role is "maintainer" (Rückfrage) or "breeder" (Antwort). + /// + public record FeedbackThreadEntry( + string Role, + string Text, + DateTimeOffset? At); + /// FEEDBACK: response DTO for a stored report. public record FeedbackDto( Guid Id, @@ -28,15 +37,24 @@ namespace GerbilManagerWebAPI.Dtos DateTimeOffset? ResolvedAt, string? Question, string? Answer, - DateTimeOffset? AnsweredAt); + DateTimeOffset? AnsweredAt, + /// Breeder-friendly changelog written on resolve (shown in the UI). + string? FixNote, + /// INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder. + string? AgentContext, + /// Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer. + IReadOnlyList Thread); /// /// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status, - /// attach a clarifying question (Rückfrage), or submit the breeder's answer. + /// attach a clarifying question (Rückfrage), submit the breeder's answer, set the + /// breeder-friendly fix note (changelog), or update the internal agent context. /// public record FeedbackUpdate( string? Message, string? Status, string? Question, - string? Answer); + string? Answer, + string? FixNote, + string? AgentContext); } diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs index 2149637..d1b47ca 100644 --- a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using Microsoft.AspNetCore.Http.HttpResults; @@ -74,15 +75,25 @@ namespace GerbilManagerWebAPI.Endpoints // Attach a clarifying question (Rückfrage). A non-empty question moves the ticket to // NeedsInfo (waiting on the breeder) unless it is already Resolved. A blank/whitespace - // question clears it. + // question clears it. If a PREVIOUS Q&A exchange already exists, archive it into the + // thread (oldest first) before overwriting — so earlier rounds aren't lost — and clear + // the current Answer so the new question starts a fresh open exchange. if (input.Question is not null) { var q = input.Question.Trim(); - entity.Question = q.Length == 0 ? null : q; - if (entity.Question is not null && !entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase)) + if (q.Length > 0) { - entity.Status = "NeedsInfo"; - entity.ResolvedAt = null; + ArchiveCurrentExchange(entity); + entity.Question = q; + if (!entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase)) + { + entity.Status = "NeedsInfo"; + entity.ResolvedAt = null; + } + } + else + { + entity.Question = null; } } @@ -124,6 +135,22 @@ namespace GerbilManagerWebAPI.Endpoints : null; } + // FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved). + // A blank/whitespace value clears it. + if (input.FixNote is not null) + { + var note = input.FixNote.Trim(); + entity.FixNote = note.Length == 0 ? null : note; + } + + // AGENT-CONTEXT (internal working memory): updated without changing the status. + // A blank/whitespace value clears it. + if (input.AgentContext is not null) + { + var c = input.AgentContext.Trim(); + entity.AgentContext = c.Length == 0 ? null : c; + } + await db.SaveChangesAsync(); return TypedResults.Ok(ToDto(entity)); }); @@ -143,9 +170,56 @@ namespace GerbilManagerWebAPI.Endpoints return app; } + private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web); + + /// + /// Append the ticket's current (Question, Answer) exchange to the thread/history JSON + /// before it gets overwritten by a new round, then clear the current Answer. Only the + /// non-empty parts are archived (a question with no answer yet is still preserved). + /// + private static void ArchiveCurrentExchange(Feedback entity) + { + var history = DeserializeThread(entity.Thread).ToList(); + var archivedAny = false; + + if (!string.IsNullOrWhiteSpace(entity.Question)) + { + history.Add(new FeedbackThreadEntry("maintainer", entity.Question!, entity.CreatedAt)); + archivedAny = true; + } + if (!string.IsNullOrWhiteSpace(entity.Answer)) + { + history.Add(new FeedbackThreadEntry("breeder", entity.Answer!, entity.AnsweredAt)); + archivedAny = true; + } + + if (archivedAny) + entity.Thread = JsonSerializer.Serialize(history, ThreadJson); + + // The new question starts a fresh open exchange — clear the prior answer. + entity.Answer = null; + entity.AnsweredAt = null; + } + + private static IReadOnlyList DeserializeThread(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + try + { + return JsonSerializer.Deserialize>(json, ThreadJson) + ?? new List(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + 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.Status, f.ResolvedAt, - f.Question, f.Answer, f.AnsweredAt); + f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext, + DeserializeThread(f.Thread)); } } diff --git a/GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs new file mode 100644 index 0000000..19f38a1 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs @@ -0,0 +1,1573 @@ +// +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("20260622192338_AddFeedbackFixNoteContextThread")] + partial class AddFeedbackFixNoteContextThread + { + /// + 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("AgentContext") + .HasColumnType("text"); + + b.Property("Answer") + .HasColumnType("text"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + 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("FixNote") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thread") + .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/20260622192338_AddFeedbackFixNoteContextThread.cs b/GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.cs new file mode 100644 index 0000000..eb71138 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddFeedbackFixNoteContextThread : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgentContext", + table: "Feedback", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "FixNote", + table: "Feedback", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Thread", + table: "Feedback", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgentContext", + table: "Feedback"); + + migrationBuilder.DropColumn( + name: "FixNote", + table: "Feedback"); + + migrationBuilder.DropColumn( + name: "Thread", + table: "Feedback"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index d3b6ea1..812e76e 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -802,6 +802,9 @@ namespace GerbilManagerWebAPI.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AgentContext") + .HasColumnType("text"); + b.Property("Answer") .HasColumnType("text"); @@ -824,6 +827,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("EntityName") .HasColumnType("text"); + b.Property("FixNote") + .HasColumnType("text"); + b.Property("GerbilId") .HasColumnType("uuid"); @@ -844,6 +850,9 @@ namespace GerbilManagerWebAPI.Migrations .IsRequired() .HasColumnType("text"); + b.Property("Thread") + .HasColumnType("text"); + b.Property("Url") .HasColumnType("text"); diff --git a/GerbilManagerWebAPI/Models/Feedback.cs b/GerbilManagerWebAPI/Models/Feedback.cs index 96283e5..2948801 100644 --- a/GerbilManagerWebAPI/Models/Feedback.cs +++ b/GerbilManagerWebAPI/Models/Feedback.cs @@ -63,5 +63,28 @@ namespace GerbilManagerWebAPI.Models /// When the breeder answered the clarifying question; null until answered. public DateTimeOffset? AnsweredAt { get; set; } + + /// + /// FIX-NOTE: a breeder-friendly changelog written when the ticket is resolved — plain + /// language ("was sich für SIE sichtbar geändert hat"), no technical jargon. Shown in the + /// "Geschlossen" view. null while the ticket is open / unresolved. + /// + public string? FixNote { get; set; } + + /// + /// AGENT-CONTEXT (INTERNAL): the maintainer/agent's working memory — findings, suspected + /// cause, files/data touched, the plan, what it is waiting on. MAY contain ids/filenames. + /// Returned by the DTO for tooling, but NEVER rendered in the breeder UI. null if unused. + /// + public string? AgentContext { get; set; } + + /// + /// THREAD/HISTORY: a JSON array of past Q&A turns so earlier rounds aren't lost when a new + /// question is asked. Each entry: { "role": "maintainer" | "breeder", "text": string, + /// "at": ISO-8601 string }. The current open exchange stays in Question/Answer; when a NEW + /// question is set while a previous Q&A exists, the previous (question, answer) is appended + /// here first. Plain string column (no FK) so it survives the ingest wipe. null/empty = no history. + /// + public string? Thread { get; set; } } } diff --git a/HUMANQUESTION.md.migrated b/HUMANQUESTION.md.migrated deleted file mode 100644 index 6556951..0000000 --- a/HUMANQUESTION.md.migrated +++ /dev/null @@ -1,105 +0,0 @@ -# HUMANQUESTION.md — offene Fragen & benötigte Daten - -> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN -> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen -> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei** -> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur -> das, was **noch offen** ist. Nichts geht verloren. - -_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle -32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der -große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit. -Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler -Feinschliff** — siehe unten._ - ---- - -## A. Zugänge & Technik (für Julian) - -| # | Was gebraucht wird | Schaltet frei | -|---|---|---| -| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. | -| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. | -| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. | -| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) | - -## B. Kleine Aktion (jederzeit) - -| # | Aktion | Warum | -|---|---|---| -| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — | - -## C. Genetik-Feinschliff (optional, blockiert nichts) - -| # | Frage | Betrifft | -|---|---|---| -| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. | -| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. | -| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen | - -## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2) - -**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …). -**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein. - -Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen. - ---- - -## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden) - -Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.) - -_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._ - -**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten): -| Tier | Sterbedatum — welches? | Quelldatei(en) | -|---|---|---| -| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx | -| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx | -| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — | -| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — | -| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx | - -**B) Gencode-Konflikt** (+ ggf. Sterbedatum): -| Tier | Konflikt — was stimmt? | Quelldatei(en) | -|---|---|---| -| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — | -| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — | -| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — | -| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — | -| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — | -| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx | -| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — | -| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx | - -*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)* - ---- - -## F. Was hängt woran (Kurzübersicht) - -| Funktion | Status | Wartet auf | -|---|---|---| -| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — | -| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) | -| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) | -| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** | -| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — | -| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) | - ---- - -## ✅ Erledigt (Archiv — nichts geht verloren) - -- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert. -- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig. -- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau). -- **C1–C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel). -- **D1–D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen). - -*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.* - ---- - -*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.* diff --git a/gerbil-manager-web/e2e/feedback.spec.ts b/gerbil-manager-web/e2e/feedback.spec.ts index 942d7a7..8f66e12 100644 --- a/gerbil-manager-web/e2e/feedback.spec.ts +++ b/gerbil-manager-web/e2e/feedback.spec.ts @@ -38,6 +38,27 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', expect(typeof report!.clientTimestamp).toBe('string') }) +test('Entwurf bleibt nach Schließen + Navigation + Wiederöffnen erhalten', async ({ page }) => { + skipUnlessMock() + // Auf einer Tier-Seite anfangen zu schreiben … + await page.goto('/rennmaeuse/kruemel') + await page.getByRole('button', { name: f.button }).click() + const dialog = page.getByRole('dialog', { name: f.dialogTitle }) + await dialog.getByLabel(f.label).fill('Halb fertiger Text, will kurz was nachschauen …') + + // … Fenster (versehentlich) schließen … + await dialog.getByRole('button', { name: f.close }).click() + await expect(dialog).not.toBeVisible() + + // … in der App woanders hin navigieren (anderer Kontext) … + await page.goto('/wuerfe/w-kruemel') + + // … „Fehler melden" erneut öffnen: der Text ist noch da. + await page.getByRole('button', { name: f.button }).click() + const dialog2 = page.getByRole('dialog', { name: f.dialogTitle }) + await expect(dialog2.getByLabel(f.label)).toHaveValue('Halb fertiger Text, will kurz was nachschauen …') +}) + test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => { skipUnlessMock() await page.goto('/wuerfe/w-kruemel') diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 35c3fb5..c9fc92b 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -412,6 +412,9 @@ export async function installMockApi(page: Page): Promise { question: null, answer: null, answeredAt: null, + fixNote: null, + agentContext: null, + thread: [], } db.feedback.push(created) return json(route, 201, created) @@ -433,19 +436,34 @@ export async function installMockApi(page: Page): Promise { status?: string question?: string answer?: string + fixNote?: string + agentContext?: 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() } - // Rückfrage anhängen → NeedsInfo (außer bereits Resolved). + // Rückfrage anhängen → NeedsInfo (außer bereits Resolved). Eine vorhandene + // Frage/Antwort-Runde wird zuerst in den Verlauf (thread) verschoben. if (typeof body.question === 'string') { const q = body.question.trim() - row.question = q.length === 0 ? null : q - if (row.question && row.status !== 'Resolved') { - row.status = 'NeedsInfo' - row.resolvedAt = null + if (q.length > 0) { + const thread = (row.thread as Record[] | undefined) ?? [] + if (typeof row.question === 'string' && row.question) + thread.push({ role: 'maintainer', text: row.question, at: row.createdAt ?? null }) + if (typeof row.answer === 'string' && row.answer) + thread.push({ role: 'breeder', text: row.answer, at: row.answeredAt ?? null }) + row.thread = thread + row.answer = null + row.answeredAt = null + row.question = q + if (row.status !== 'Resolved') { + row.status = 'NeedsInfo' + row.resolvedAt = null + } + } else { + row.question = null } } // Antwort der Züchterin → Answered + answeredAt. @@ -471,6 +489,16 @@ export async function installMockApi(page: Page): Promise { : 'Open' row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null } + // Changelog (fixNote) — laienverständlich, sichtbar. + if (typeof body.fixNote === 'string') { + const note = body.fixNote.trim() + row.fixNote = note.length === 0 ? null : note + } + // Internes Agenten-Arbeitsgedächtnis — ändert den Status nicht. + if (typeof body.agentContext === 'string') { + const c = body.agentContext.trim() + row.agentContext = c.length === 0 ? null : c + } return json(route, 200, row) } if (method === 'DELETE') { diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index aa3a378..e4a45c4 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -411,9 +411,15 @@ export function seedDb(): MockDb { question: null, answer: null, answeredAt: null, + // Changelog (laienverständlich) für die geschlossene Ansicht. + fixNote: 'Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.', + // INTERN: darf NIE in der Züchterin-UI erscheinen. + agentContext: 'GEHEIM-AGENT-CONTEXT: motherId via conflict-decisions.json gesetzt, re-ingest.', + thread: [], }, { // Ticket mit offener Rückfrage (NeedsInfo): die Züchterin soll hier antworten können. + // Hat bereits eine frühere, beantwortete Runde im Verlauf (thread). id: 'feedback-needsinfo', message: 'Der Wurf B hat ein falsches Geburtsdatum.', context: 'litter-detail', @@ -430,6 +436,13 @@ export function seedDb(): MockDb { question: 'Welches Geburtsdatum ist korrekt — der 1. oder der 2. Juni?', answer: null, answeredAt: null, + fixNote: null, + agentContext: 'GEHEIM-AGENT-CONTEXT: warte auf DOB-Bestätigung für Wurf B.', + // Frühere, abgeschlossene Runde (älteste zuerst) — bleibt im Verlauf erhalten. + thread: [ + { role: 'maintainer', text: 'Wurde der Wurf im Mai oder Juni geboren?', at: '2026-06-13T08:00:00Z' }, + { role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' }, + ], }, { id: 'feedback-open', @@ -448,6 +461,9 @@ export function seedDb(): MockDb { question: null, answer: null, answeredAt: null, + fixNote: null, + agentContext: null, + thread: [], }, ], } diff --git a/gerbil-manager-web/e2e/tickets.spec.ts b/gerbil-manager-web/e2e/tickets.spec.ts index 3c2dde2..83526c3 100644 --- a/gerbil-manager-web/e2e/tickets.spec.ts +++ b/gerbil-manager-web/e2e/tickets.spec.ts @@ -1,28 +1,99 @@ /** * 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. + * Erreichbar über Hilfe → „Meine Tickets". Drei gefilterte Verlinkungen (Filter-Chips): + * „Rückfragen" (im Dialog), „Offen", „Geschlossen" (zeigt den Changelog/fixNote). + * Pro Ticket: gelöst markieren / wieder öffnen, Nachricht bearbeiten, antworten, löschen, + * Frage/Antwort-Verlauf (thread). Das interne `agentContext` wird NIE gerendert. */ import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures' const tt = de.feedback.tickets +/** In die gewünschte gefilterte Ansicht wechseln (Filter-Chip klicken). */ +async function selectView(page: import('@playwright/test').Page, label: string) { + await page.locator('.tickets-filter').filter({ hasText: label }).click() +} + test.describe('Meine Tickets', () => { test.beforeEach(() => skipUnlessMock()) - test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => { + test('über Hilfe erreichbar, Default-Ansicht „Offen" zeigt offenes 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. + // Default = „Offen": offenes Ticket + dessen Nachricht sichtbar. 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"). + test('drei gefilterte Verlinkungen filtern korrekt, Anzahl-Badges stimmen', async ({ page }) => { + await page.goto('/hilfe/tickets') + + // Filter-Chips vorhanden, jeweils mit Anzahl-Badge (1 pro Gruppe in den Seed-Daten). + const dialog = page.locator('.tickets-filter').filter({ hasText: tt.filters.dialog }) + const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open }) + const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed }) + await expect(dialog.locator('.tickets-filter__count')).toHaveText('1') + await expect(open.locator('.tickets-filter__count')).toHaveText('1') + await expect(closed.locator('.tickets-filter__count')).toHaveText('1') + + // „Offen": nur das offene Ticket. + await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible() + await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0) + await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toHaveCount(0) + + // „Rückfragen": nur das Dialog-Ticket (NeedsInfo). + await selectView(page, tt.filters.dialog) + await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toBeVisible() + await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toHaveCount(0) + + // „Geschlossen": nur das gelöste Ticket. + await selectView(page, tt.filters.closed) await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible() - await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible() + await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0) + }) + + test('geschlossenes Ticket zeigt den Changelog (fixNote) + Datum', async ({ page }) => { + await page.goto('/hilfe/tickets') + await selectView(page, tt.filters.closed) + + const card = page + .locator('.ticket-card') + .filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }) + await expect(card.getByText(tt.changelogLabel)).toBeVisible() + await expect( + card.getByText('Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.'), + ).toBeVisible() + // Datum „Gelöst am" in der Meta-Leiste. + await expect(card.getByText(tt.resolvedLabel)).toBeVisible() + }) + + test('agentContext wird NIE in der Züchterin-UI gerendert (in allen Ansichten)', async ({ + page, + }) => { + await page.goto('/hilfe/tickets') + // In jeder Ansicht prüfen: der interne Kontext-Text taucht nirgends auf. + for (const view of [tt.filters.open, tt.filters.dialog, tt.filters.closed]) { + await selectView(page, view) + await expect(page.getByText('GEHEIM-AGENT-CONTEXT', { exact: false })).toHaveCount(0) + } + }) + + test('Verlauf (thread) früherer Runden wird angezeigt', async ({ page }) => { + await page.goto('/hilfe/tickets') + await selectView(page, tt.filters.dialog) + + const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' }) + await expect(card.getByText(tt.threadLabel)).toBeVisible() + // Frühere Runde (Frage + Antwort) sichtbar. + await expect(card.getByText('Wurde der Wurf im Mai oder Juni geboren?')).toBeVisible() + await expect(card.getByText('Im Juni.')).toBeVisible() + // Aktuelle offene Rückfrage ebenfalls sichtbar. + await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible() }) test('Ticket als gelöst markieren', async ({ page }) => { @@ -35,17 +106,20 @@ test.describe('Meine Tickets', () => { 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() + // Das Ticket verlässt die „Offen"-Ansicht — die Ansicht weicht automatisch auf + // eine nicht-leere Gruppe aus. Das gelöste Ticket findet sich unter „Geschlossen". + await selectView(page, tt.filters.closed) + const resolvedCard = page + .locator('.ticket-card') + .filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' }) + await expect(resolvedCard.locator('.ticket-badge--resolved')).toBeVisible() + await expect(resolvedCard.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. + // „Offen"-Ansicht (Default): das offene Ticket über den Datensatz-Namen adressieren. 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() @@ -61,6 +135,7 @@ test.describe('Meine Tickets', () => { page, }) => { await page.goto('/hilfe/tickets') + await selectView(page, tt.filters.dialog) const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' }) await expect(card).toBeVisible() @@ -70,10 +145,10 @@ test.describe('Meine Tickets', () => { await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible() // Antworten. - await card.locator('textarea').fill('Korrekt ist der 2. Juni.') + await card.locator('.ticket-card__answer-form textarea').fill('Korrekt ist der 2. Juni.') await card.getByRole('button', { name: tt.answerButton }).click() - // Antwort wird angezeigt + Badge wechselt auf „Beantwortet". + // Antwort wird angezeigt + Badge wechselt auf „Beantwortet" (bleibt in der Dialog-Ansicht). await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible() await expect(card.locator('.ticket-badge--answered')).toBeVisible() await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible() @@ -81,6 +156,7 @@ test.describe('Meine Tickets', () => { test('Ticket löschen', async ({ page }) => { await page.goto('/hilfe/tickets') + await selectView(page, tt.filters.closed) const card = page .locator('.ticket-card') diff --git a/gerbil-manager-web/src/api/feedback.ts b/gerbil-manager-web/src/api/feedback.ts index 9075b21..626025e 100644 --- a/gerbil-manager-web/src/api/feedback.ts +++ b/gerbil-manager-web/src/api/feedback.ts @@ -27,6 +27,15 @@ export interface FeedbackInput { */ export type FeedbackStatus = 'Open' | 'NeedsInfo' | 'Answered' | 'Resolved' +/** Eine vergangene Frage/Antwort-Runde im Verlauf (Thread) eines Tickets. */ +export interface FeedbackThreadEntry { + /** „maintainer" = Rückfrage, „breeder" = Antwort der Züchterin. */ + role: 'maintainer' | 'breeder' + text: string + /** Zeitpunkt der Runde (ISO-8601) oder null. */ + at: string | null +} + export interface Feedback { id: string message: string @@ -47,6 +56,15 @@ export interface Feedback { answer: string | null /** Zeitpunkt der Antwort der Züchterin. */ answeredAt: string | null + /** Laienverständlicher Changelog, der beim Schließen geschrieben wird (sichtbar). */ + fixNote: string | null + /** + * INTERN: Arbeitsgedächtnis des Agenten — wird vom Backend geliefert, darf aber + * NIEMALS in der Züchterin-UI angezeigt werden. + */ + agentContext: string | null + /** Frühere Frage/Antwort-Runden (älteste zuerst); die aktuelle offene Runde bleibt in question/answer. */ + thread: FeedbackThreadEntry[] } /** Alias used by the "Meine Tickets" area for readability. */ @@ -63,6 +81,10 @@ export interface FeedbackUpdate { question?: string /** Antwort der Züchterin (nicht leer ⇒ Status Answered + answeredAt). */ answer?: string + /** Laienverständlicher Changelog (typisch zusammen mit status: "Resolved" gesetzt). */ + fixNote?: string + /** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */ + agentContext?: string } export function submitFeedback(body: FeedbackInput): Promise { diff --git a/gerbil-manager-web/src/components/ReportErrorDialog.tsx b/gerbil-manager-web/src/components/ReportErrorDialog.tsx index 30df944..ca9e448 100644 --- a/gerbil-manager-web/src/components/ReportErrorDialog.tsx +++ b/gerbil-manager-web/src/components/ReportErrorDialog.tsx @@ -44,10 +44,32 @@ function ReportErrorDialogBody({ }) { const t = de.feedback const toast = useToast() - const [message, setMessage] = useState('') + // EIN gemeinsamer Entwurf fürs Melde-Fenster (nicht pro Kontext): so kommt der Text auch + // dann zurück, wenn das Fenster (versehentlich) geschlossen wird, man in der App woanders + // hin navigiert (anderer Kontext) und „Fehler melden" erneut öffnet — oder die Seite am + // Handy beim App-Wechsel aus dem Speicher geworfen wird. Wird bei jeder Eingabe gesichert + // und erst nach erfolgreichem Senden gelöscht. + const draftKey = 'report-error-draft' + const [message, setMessage] = useState(() => { + try { + return localStorage.getItem(draftKey) ?? '' + } catch { + return '' + } + }) const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + // Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer. + useEffect(() => { + try { + if (message) localStorage.setItem(draftKey, message) + else localStorage.removeItem(draftKey) + } catch { + /* localStorage nicht verfügbar — Entwurf dann nur im Speicher */ + } + }, [message, draftKey]) + // Focus the textarea once on mount. useEffect(() => { const id = window.setTimeout(() => textareaRef.current?.focus(), 0) @@ -84,6 +106,11 @@ function ReportErrorDialogBody({ url: window.location.href, clientTimestamp: new Date().toISOString(), }) + try { + localStorage.removeItem(draftKey) + } catch { + /* ignorieren */ + } toast.success(t.success) onClose() } catch (err) { diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index 8b07ffa..17a868a 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -5,9 +5,13 @@ * 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. + * + * Drei gefilterte Verlinkungen (Filter-Chips, kein Pflicht-Tab): „Rückfragen" (im Dialog + * = NeedsInfo/Answered), „Offen" (Open), „Geschlossen" (Resolved, zeigt den Changelog/fixNote). + * Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS. */ -import { useState } from 'react' -import { Link } from 'react-router-dom' +import { createContext, Fragment, useContext, useEffect, useMemo, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' import { @@ -16,10 +20,23 @@ import { deleteFeedback, type FeedbackTicket, } from '../api/feedback' +import { getGerbil } from '../api/gerbils' import { useApi } from '../hooks/useApi' import { useToast } from '../components/toast' import './tickets.css' +/** + * Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene + * durchgereicht werden muss: + * - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket). + * - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte). + * Wert `undefined` = noch nicht geladen, `null` = nicht gefunden. + */ +const TicketRefsContext = createContext<{ + ticketIds: Set + gerbilNames: Record +}>({ ticketIds: new Set(), gerbilNames: {} }) + const t = de.feedback.tickets const ctx = de.feedback.contexts const nav = de.nav @@ -29,6 +46,104 @@ function contextLabel(context: string): string { return (ctx as Record)[context] ?? context } +/** Interne Route zum referenzierten Datensatz (Tier/Wurf/Kontakt) eines Tickets, falls vorhanden. */ +function entityHref(ticket: FeedbackTicket): string | null { + if (ticket.gerbilId) return `/rennmaeuse/${ticket.gerbilId}` + if (ticket.litterId) return `/wuerfe/${ticket.litterId}` + if (ticket.contactId) return `/kontakte/${ticket.contactId}` + return null +} + +const GUID_RE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + +type RichToken = { kind: 'text'; value: string } | { kind: 'link'; href: string; label: string } + +/** + * Zerlegt Ticket-Text in Text- und Link-Token. Erkannt werden: + * - Markdown-Links `[Label](/route)` → interner/externer Link, + * - nackte GUIDs: gehört sie zu einem bekannten Ticket → Link auf dieses Ticket; sonst, wenn + * sie einen Tier-Namen auflöst → Name + Link zur Rennmausakte (so kann die KI einfach die + * Tier-ID in den Text schreiben). Unauflösbare GUIDs bleiben Text. + */ +function tokenizeRich( + text: string, + ticketIds: Set, + gerbilNames: Record, +): RichToken[] { + const tokens: RichToken[] = [] + const re = new RegExp(`\\[([^\\]]+)\\]\\(([^)]+)\\)|(${GUID_RE})`, 'g') + let last = 0 + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + if (m.index > last) tokens.push({ kind: 'text', value: text.slice(last, m.index) }) + if (m[1] !== undefined && m[2] !== undefined) { + tokens.push({ kind: 'link', href: m[2], label: m[1] }) + } else { + const guid = m[3] + const gerbilName = gerbilNames[guid] + if (ticketIds.has(guid)) { + tokens.push({ kind: 'link', href: `/hilfe/tickets?focus=${guid}`, label: t.ticketRefLabel }) + } else if (gerbilName) { + tokens.push({ kind: 'link', href: `/rennmaeuse/${guid}`, label: gerbilName }) + } else { + tokens.push({ kind: 'text', value: guid }) + } + } + last = re.lastIndex + } + if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) }) + return tokens +} + +/** Alle nackten GUIDs in einem Text, die KEINE Markdown-Link-Ziele sind (mögliche Tier-IDs). */ +function bareGuids(text: string): string[] { + const stripped = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + return stripped.match(new RegExp(GUID_RE, 'g')) ?? [] +} + +/** Plain-Text mit erhaltenen Zeilenumbrüchen. */ +function TextWithBreaks({ value }: { value: string }) { + const lines = value.split('\n') + return ( + <> + {lines.map((line, i) => ( + + {i > 0 &&
} + {line} +
+ ))} + + ) +} + +/** Rendert Ticket-Text mit klickbaren Tier-/Ticket-Links und Zeilenumbrüchen. */ +function RichText({ text }: { text: string }) { + const { ticketIds, gerbilNames } = useContext(TicketRefsContext) + const tokens = useMemo( + () => tokenizeRich(text, ticketIds, gerbilNames), + [text, ticketIds, gerbilNames], + ) + return ( + <> + {tokens.map((tok, i) => + tok.kind === 'link' ? ( + tok.href.startsWith('/') ? ( + + {tok.label} + + ) : ( + + {tok.label} + + ) + ) : ( + + ), + )} + + ) +} + function formatDate(iso: string | null): string { if (!iso) return '—' const d = new Date(iso) @@ -36,9 +151,24 @@ function formatDate(iso: string | null): string { return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } +/** Gefilterte Ansichten (Filter-Chips). */ +type TicketView = 'dialog' | 'open' | 'closed' + +/** Zuordnung Ticket-Status → gefilterte Ansicht. */ +function viewOf(status: FeedbackTicket['status']): TicketView { + if (status === 'Resolved') return 'closed' + if (status === 'NeedsInfo' || status === 'Answered') return 'dialog' + return 'open' +} + export default function TicketsPage() { const toast = useToast() const tickets = useApi(() => listFeedback(), []) + // Default-Ansicht: „Offen" (neue, noch unbearbeitete Tickets) — fällt auf die erste + // nicht-leere Gruppe zurück, falls „Offen" leer ist. Solange die Nutzerin keinen Filter + // selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen. + const [view, setView] = useState('open') + const [userPicked, setUserPicked] = useState(false) async function handleToggleStatus(ticket: FeedbackTicket) { const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved' @@ -76,6 +206,93 @@ export default function TicketsPage() { const rows = tickets.data + // Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus= wechselt in die passende + // Ansicht und hebt das Ticket hervor. ticketIds = alle bekannten IDs (für nackte-GUID-Links). + const [searchParams] = useSearchParams() + const focusId = searchParams.get('focus') + const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows]) + // Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link). + const [gerbilNames, setGerbilNames] = useState>({}) + + // Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips). + const counts = useMemo(() => { + const c: Record = { dialog: 0, open: 0, closed: 0 } + for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1 + return c + }, [rows]) + + // Ansicht des per ?focus= angesprungenen Tickets (ohne setState im Effekt abgeleitet). + const focusView = useMemo(() => { + if (!focusId || !rows) return null + const target = rows.find((r) => r.id === focusId) + return target ? viewOf(target.status) : null + }, [focusId, rows]) + + // Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert), + // sonst der gewählte Filter; falls leer, auf die erste nicht-leere Gruppe ausweichen. + const desiredView: TicketView = focusView && !userPicked ? focusView : view + const activeView: TicketView = + counts[desiredView] > 0 + ? desiredView + : (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView + + const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView) + + // Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben. + useEffect(() => { + if (!focusId) return + const el = document.getElementById(`ticket-${focusId}`) + if (!el) return + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.classList.add('ticket-card--focus') + const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500) + return () => clearTimeout(timer) + }, [focusId, visible]) + + // Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen, + // damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte). + const candidateGerbilIds = useMemo(() => { + const ids = new Set() + const add = (txt?: string | null) => { + if (!txt) return + for (const g of bareGuids(txt)) if (!ticketIds.has(g)) ids.add(g) + } + for (const tk of visible) { + add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote) + for (const e of tk.thread) add(e.text) + } + return ids + }, [visible, ticketIds]) + + useEffect(() => { + const missing = [...candidateGerbilIds].filter((id) => !(id in gerbilNames)) + if (missing.length === 0) return + let cancelled = false + Promise.all( + missing.map((id) => + getGerbil(id) + .then((g) => [id, g.name] as const) + .catch(() => [id, null] as const), + ), + ).then((pairs) => { + if (cancelled) return + setGerbilNames((prev) => { + const next = { ...prev } + for (const [id, name] of pairs) next[id] = name + return next + }) + }) + return () => { + cancelled = true + } + }, [candidateGerbilIds, gerbilNames]) + + const emptyText: Record = { + dialog: t.emptyDialog, + open: t.emptyOpen, + closed: t.emptyClosed, + } + return (
) @@ -135,8 +378,26 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(ticket.message) const [saving, setSaving] = useState(false) - const [answerDraft, setAnswerDraft] = useState('') + // Antwort-Entwurf bleibt beim Verlassen/Zurückkehren erhalten (z. B. nach Klick auf + // einen Tier-Link im Frage-Text) — pro Ticket in localStorage gepuffert. + const draftKey = `ticket-answer-draft-${ticket.id}` + const [answerDraft, setAnswerDraft] = useState(() => { + try { + return localStorage.getItem(draftKey) ?? '' + } catch { + return '' + } + }) + useEffect(() => { + try { + if (answerDraft) localStorage.setItem(draftKey, answerDraft) + else localStorage.removeItem(draftKey) + } catch { + /* localStorage nicht verfügbar — Entwurf dann nur im Speicher */ + } + }, [answerDraft, draftKey]) const [answering, setAnswering] = useState(false) + const entityLink = entityHref(ticket) const resolved = ticket.status === 'Resolved' const badge = statusBadge(ticket.status) // Die Züchterin darf antworten, solange eine Rückfrage offen ist und noch keine @@ -183,11 +444,18 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete } return ( -
  • +
  • {badge.label} {contextLabel(ticket.context)} - {ticket.entityName && {ticket.entityName}} + {ticket.entityName && + (entityLink ? ( + + {ticket.entityName} + + ) : ( + {ticket.entityName} + ))}
    {editing ? ( @@ -214,19 +482,49 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete ) : ( <> -

    {ticket.message}

    +

    + +

    + + {ticket.thread.length > 0 && ( +
    + {t.threadLabel} +
      + {ticket.thread.map((entry, i) => ( +
    1. + + {entry.role === 'maintainer' ? t.threadMaintainer : t.threadBreeder} + +

      + +

      + {entry.at && ( + {formatDate(entry.at)} + )} +
    2. + ))} +
    +
    + )} {ticket.question && (
    {t.questionLabel} -

    {ticket.question}

    +

    + +

    )} {ticket.answer ? (
    {t.answerLabel} -

    {ticket.answer}

    +

    + +

    {ticket.answeredAt && ( {t.answeredOn}: {formatDate(ticket.answeredAt)} @@ -259,6 +557,15 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
    ) : null} + {resolved && ticket.fixNote && ( +
    + {t.changelogLabel} +

    + +

    +
    + )} +
    {t.createdLabel}
    diff --git a/gerbil-manager-web/src/pages/tickets.css b/gerbil-manager-web/src/pages/tickets.css index 0c902a8..925356d 100644 --- a/gerbil-manager-web/src/pages/tickets.css +++ b/gerbil-manager-web/src/pages/tickets.css @@ -40,6 +40,55 @@ margin-bottom: 1rem; } +/* GEFILTERTE VERLINKUNGEN: Filter-Chips (wie die Rennmaus-Liste). */ +.tickets-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.tickets-filter { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font: inherit; + font-size: 0.85rem; + font-weight: 600; + padding: 0.35rem 0.8rem; + border: 1px solid var(--color-border, #ddd); + border-radius: 999px; + background: var(--color-surface, #fff); + color: var(--color-text, #333); + cursor: pointer; +} + +.tickets-filter:hover { + border-color: var(--color-accent, #2563eb); +} + +.tickets-filter--active { + background: var(--color-accent, #2563eb); + border-color: var(--color-accent, #2563eb); + color: #fff; +} + +.tickets-filter__count { + font-size: 0.75rem; + font-weight: 700; + min-width: 1.25rem; + text-align: center; + padding: 0.05rem 0.4rem; + border-radius: 999px; + background: var(--color-surface-2, #eef2f7); + color: var(--color-muted, #555); +} + +.tickets-filter--active .tickets-filter__count { + background: rgba(255, 255, 255, 0.25); + color: #fff; +} + .tickets-list { list-style: none; margin: 0; @@ -173,6 +222,95 @@ margin: 0 0 0.75rem; } +/* VERLAUF (Thread): frühere Frage/Antwort-Runden, dezent. */ +.ticket-card__thread { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--color-border, #d4d4d4); + background: var(--color-surface-2, #f7f8fa); + border-radius: 0 8px 8px 0; +} + +.ticket-card__thread-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-muted, #777); + margin-bottom: 0.4rem; +} + +.ticket-card__thread-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ticket-card__thread-entry { + padding-left: 0.6rem; + border-left: 2px solid var(--color-border, #ccc); +} + +.ticket-card__thread-entry--maintainer { + border-left-color: var(--color-warning, #f59e0b); +} + +.ticket-card__thread-entry--breeder { + border-left-color: var(--color-accent, #2563eb); +} + +.ticket-card__thread-role { + display: block; + font-size: 0.72rem; + font-weight: 700; + color: var(--color-muted, #777); +} + +.ticket-card__thread-text { + margin: 0.1rem 0 0; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.92rem; +} + +.ticket-card__thread-date { + display: block; + margin-top: 0.15rem; + font-size: 0.74rem; + color: var(--color-muted, #999); +} + +/* CHANGELOG (fixNote): laienverständlich, grüner Akzent auf geschlossenen Tickets. */ +.ticket-card__changelog { + border-left: 3px solid var(--color-success, #22c55e); + background: var(--color-success-bg, #f0fdf4); + padding: 0.55rem 0.75rem; + border-radius: 0 8px 8px 0; + margin: 0 0 0.85rem; +} + +.ticket-card__changelog-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-success-text, #166534); + margin-bottom: 0.25rem; +} + +.ticket-card__changelog-text { + margin: 0; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + .ticket-card__meta { display: flex; flex-wrap: wrap; @@ -227,3 +365,40 @@ outline: 2px solid var(--color-accent, #2563eb); outline-offset: 1px; } + +/* Klickbare Tier-/Ticket-Links im Ticket-Text. */ +.ticket-link { + color: var(--color-accent, #2563eb); + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-word; +} + +.ticket-link:hover { + text-decoration: none; +} + +/* Verlinkter Datensatz-Name (Tier/Wurf/Kontakt) in der Kopfzeile. */ +.ticket-card__entity--link { + color: var(--color-accent, #2563eb); + text-decoration: underline; + text-underline-offset: 2px; +} + +.ticket-card__entity--link:hover { + text-decoration: none; +} + +/* Kurzes Hervorheben des per ?focus= angesprungenen Tickets. */ +.ticket-card--focus { + animation: ticket-focus-pulse 2.5s ease-out; +} + +@keyframes ticket-focus-pulse { + 0%, 30% { + box-shadow: 0 0 0 3px var(--color-accent, #2563eb); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 30cf5e3..7abefc2 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -875,6 +875,7 @@ export const de = { 'gerbil-detail': 'Rennmausakte', 'litter-detail': 'Wurf', 'contact-detail': 'Kontakt', + 'rpro3-import': 'RennmausPro-Import', }, submit: 'Senden', submitting: 'Wird gesendet …', @@ -929,6 +930,30 @@ export const de = { answeredOn: 'Beantwortet am', answeredToast: 'Antwort gesendet.', emptyAnswer: 'Bitte gib eine Antwort ein.', + /** Gefilterte Verlinkungen (wie die Filter-Chips der Rennmaus-Liste). */ + filters: { + /** Gruppe „Rückfragen": Tickets im Dialog (NeedsInfo + Answered). */ + dialog: 'Rückfragen', + /** Gruppe „Offen": noch nicht bearbeitete Tickets. */ + open: 'Offen', + /** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */ + closed: 'Geschlossen', + }, + /** Leertext je gefilterter Ansicht. */ + emptyDialog: 'Aktuell keine Tickets im Dialog.', + emptyOpen: 'Keine offenen Tickets.', + emptyClosed: 'Noch keine geschlossenen Tickets.', + /** Changelog (fixNote) auf geschlossenen Tickets. */ + changelogLabel: 'Was wurde geändert', + /** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */ + threadLabel: 'Bisheriger Verlauf', + /** Rollen-Beschriftungen im Verlauf. */ + threadMaintainer: 'Rückfrage', + threadBreeder: 'Deine Antwort', + /** Tooltip am verlinkten Datensatz-Namen (Tier/Wurf/Kontakt). */ + entityLinkTitle: 'Datensatz ansehen', + /** Link-Beschriftung für eine im Text referenzierte andere Ticket-Nummer. */ + ticketRefLabel: 'verknüpftes Ticket ansehen', }, }, /** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */