feat(tickets): Rückfragen-Dialog, Verlinkungen, Entwurfssicherung + PWA-Vorbereitung
- 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 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -159,6 +159,97 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
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<ApiFactory>
|
||||
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<ApiFactory>
|
||||
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);
|
||||
|
||||
@@ -11,6 +11,15 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: one past Q&A turn in the ticket's thread/history.
|
||||
/// Role is "maintainer" (Rückfrage) or "breeder" (Antwort).
|
||||
/// </summary>
|
||||
public record FeedbackThreadEntry(
|
||||
string Role,
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
@@ -28,15 +37,24 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? AnsweredAt);
|
||||
DateTimeOffset? AnsweredAt,
|
||||
/// <summary>Breeder-friendly changelog written on resolve (shown in the UI).</summary>
|
||||
string? FixNote,
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record FeedbackUpdate(
|
||||
string? Message,
|
||||
string? Status,
|
||||
string? Question,
|
||||
string? Answer);
|
||||
string? Answer,
|
||||
string? FixNote,
|
||||
string? AgentContext);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<FeedbackThreadEntry> DeserializeThread(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Array.Empty<FeedbackThreadEntry>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<FeedbackThreadEntry>>(json, ThreadJson)
|
||||
?? new List<FeedbackThreadEntry>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<FeedbackThreadEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
1573
GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs
generated
Normal file
1573
GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackFixNoteContextThread : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AgentContext",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FixNote",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Thread",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgentContext",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FixNote",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Thread",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -802,6 +802,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AgentContext")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Answer")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -824,6 +827,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FixNote")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -844,6 +850,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Thread")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
||||
@@ -63,5 +63,28 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
/// <summary>When the breeder answered the clarifying question; null until answered.</summary>
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? FixNote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? AgentContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? Thread { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.*
|
||||
@@ -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')
|
||||
|
||||
@@ -412,6 +412,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
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<MockDb> {
|
||||
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<string, unknown>[] | 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<MockDb> {
|
||||
: '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') {
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<Feedback> {
|
||||
|
||||
@@ -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<HTMLTextAreaElement | null>(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) {
|
||||
|
||||
@@ -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<string>
|
||||
gerbilNames: Record<string, string | null>
|
||||
}>({ 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<string, string>)[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<string>,
|
||||
gerbilNames: Record<string, string | null>,
|
||||
): 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) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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('/') ? (
|
||||
<Link key={i} to={tok.href} className="ticket-link">
|
||||
{tok.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a key={i} href={tok.href} className="ticket-link" target="_blank" rel="noreferrer">
|
||||
{tok.label}
|
||||
</a>
|
||||
)
|
||||
) : (
|
||||
<TextWithBreaks key={i} value={tok.value} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<TicketView>('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=<id> 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<Record<string, string | null>>({})
|
||||
|
||||
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0 }
|
||||
for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1
|
||||
return c
|
||||
}, [rows])
|
||||
|
||||
// Ansicht des per ?focus=<id> angesprungenen Tickets (ohne setState im Effekt abgeleitet).
|
||||
const focusView = useMemo<TicketView | null>(() => {
|
||||
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<string>()
|
||||
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<TicketView, string> = {
|
||||
dialog: t.emptyDialog,
|
||||
open: t.emptyOpen,
|
||||
closed: t.emptyClosed,
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page tickets-page">
|
||||
<nav className="tickets-breadcrumb" aria-label="Brotkrümel">
|
||||
@@ -91,18 +308,44 @@ export default function TicketsPage() {
|
||||
{rows === null ? null : rows.length === 0 && !tickets.error ? (
|
||||
<p className="tickets-empty">{t.empty}</p>
|
||||
) : (
|
||||
<ul className="tickets-list">
|
||||
{rows.map((ticket) => (
|
||||
<TicketCard
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
onToggleStatus={() => handleToggleStatus(ticket)}
|
||||
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
||||
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
||||
onDelete={() => handleDelete(ticket)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<div className="tickets-filters" role="group" aria-label={t.title}>
|
||||
{(['dialog', 'open', 'closed'] as TicketView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`tickets-filter${v === activeView ? ' tickets-filter--active' : ''}`}
|
||||
aria-pressed={v === activeView}
|
||||
onClick={() => {
|
||||
setUserPicked(true)
|
||||
setView(v)
|
||||
}}
|
||||
>
|
||||
{t.filters[v]}
|
||||
<span className="tickets-filter__count">{counts[v]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="tickets-empty">{emptyText[activeView]}</p>
|
||||
) : (
|
||||
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames }}>
|
||||
<ul className="tickets-list">
|
||||
{visible.map((ticket) => (
|
||||
<TicketCard
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
onToggleStatus={() => handleToggleStatus(ticket)}
|
||||
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
||||
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
||||
onDelete={() => handleDelete(ticket)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</TicketRefsContext.Provider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
@@ -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 (
|
||||
<li className={`ticket-card${resolved ? ' ticket-card--resolved' : ''}`}>
|
||||
<li id={`ticket-${ticket.id}`} className={`ticket-card${resolved ? ' ticket-card--resolved' : ''}`}>
|
||||
<div className="ticket-card__top">
|
||||
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
|
||||
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
|
||||
{ticket.entityName && <span className="ticket-card__entity">{ticket.entityName}</span>}
|
||||
{ticket.entityName &&
|
||||
(entityLink ? (
|
||||
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
|
||||
{ticket.entityName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="ticket-card__entity">{ticket.entityName}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
@@ -214,19 +482,49 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="ticket-card__message">{ticket.message}</p>
|
||||
<p className="ticket-card__message">
|
||||
<RichText text={ticket.message} />
|
||||
</p>
|
||||
|
||||
{ticket.thread.length > 0 && (
|
||||
<div className="ticket-card__thread">
|
||||
<span className="ticket-card__thread-label">{t.threadLabel}</span>
|
||||
<ol className="ticket-card__thread-list">
|
||||
{ticket.thread.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`ticket-card__thread-entry ticket-card__thread-entry--${entry.role}`}
|
||||
>
|
||||
<span className="ticket-card__thread-role">
|
||||
{entry.role === 'maintainer' ? t.threadMaintainer : t.threadBreeder}
|
||||
</span>
|
||||
<p className="ticket-card__thread-text">
|
||||
<RichText text={entry.text} />
|
||||
</p>
|
||||
{entry.at && (
|
||||
<span className="ticket-card__thread-date">{formatDate(entry.at)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.question && (
|
||||
<div className="ticket-card__question">
|
||||
<span className="ticket-card__question-label">{t.questionLabel}</span>
|
||||
<p className="ticket-card__question-text">{ticket.question}</p>
|
||||
<p className="ticket-card__question-text">
|
||||
<RichText text={ticket.question} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.answer ? (
|
||||
<div className="ticket-card__answer">
|
||||
<span className="ticket-card__answer-label">{t.answerLabel}</span>
|
||||
<p className="ticket-card__answer-text">{ticket.answer}</p>
|
||||
<p className="ticket-card__answer-text">
|
||||
<RichText text={ticket.answer} />
|
||||
</p>
|
||||
{ticket.answeredAt && (
|
||||
<span className="ticket-card__answer-date">
|
||||
{t.answeredOn}: {formatDate(ticket.answeredAt)}
|
||||
@@ -259,6 +557,15 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{resolved && ticket.fixNote && (
|
||||
<div className="ticket-card__changelog">
|
||||
<span className="ticket-card__changelog-label">{t.changelogLabel}</span>
|
||||
<p className="ticket-card__changelog-text">
|
||||
<RichText text={ticket.fixNote} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="ticket-card__meta">
|
||||
<div>
|
||||
<dt>{t.createdLabel}</dt>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user