feat(tickets): Wiederöffnen→Rückfrage mit Zeitstempel + Soft-Delete-Papierkorb + Scroll-Erhalt

Wiederöffnen eines gelösten Tickets:
- landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen".
- War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und
  „Wieder geöffnet am" (ReopenedAt) gestempelt.
- Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es
  ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück.

Löschen über die UI = Soft-Delete:
- Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden.
- Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null).
- Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung
  (lazy beim Abruf in GET /feedback).

Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen
Tier-Link) erhalten, statt an den Seitenanfang zu springen.

EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable).
Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets
Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:02:04 +02:00
parent a0fa33da42
commit c0a7b1a5ee
13 changed files with 2237 additions and 43 deletions

View File

@@ -100,15 +100,80 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Assert.Equal("Open", reopened.GetProperty("status").GetString()); Assert.Equal("Open", reopened.GetProperty("status").GetString());
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind); Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
// Delete -> 204, then 404 on subsequent edit/delete // Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
var del = await client.DeleteAsync($"/feedback/{id}"); var del = await client.DeleteAsync($"/feedback/{id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var delAgain = await client.DeleteAsync($"/feedback/{id}"); var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode); var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" }); // Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode); var restore = await client.PostAsync($"/feedback/{id}/restore", null);
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);
// Delete/Restore eines unbekannten Tickets -> 404.
var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
}
[Fact]
public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Farbschlag stimmt nicht.",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Direkt (ohne Rückfrage) lösen …
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });
// … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
{
status = "NeedsInfo",
question = "Bitte beschreibe, was noch fehlt.",
});
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
// Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
}
[Fact]
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Welches Tier ist gemeint?",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Rückfrage stellen (NeedsInfo) …
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
// … manuell lösen …
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
// … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
{
status = "NeedsInfo",
question = "Meinst du A oder B?",
});
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
// Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
} }
[Fact] [Fact]

View File

@@ -43,7 +43,11 @@ namespace GerbilManagerWebAPI.Dtos
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary> /// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
string? AgentContext, string? AgentContext,
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary> /// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
IReadOnlyList<FeedbackThreadEntry> Thread); IReadOnlyList<FeedbackThreadEntry> Thread,
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
DateTimeOffset? ReopenedAt = null,
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
DateTimeOffset? DeletedAt = null);
/// <summary> /// <summary>
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status, /// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,

View File

@@ -18,6 +18,9 @@ namespace GerbilManagerWebAPI.Endpoints
/// </summary> /// </summary>
public static class FeedbackEndpoints public static class FeedbackEndpoints
{ {
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
private const int TrashRetentionDays = 30;
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/feedback").WithTags("Feedback"); var group = app.MapGroup("/feedback").WithTags("Feedback");
@@ -51,8 +54,21 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/", async (ApplicationContext db) => group.MapGet("/", async (ApplicationContext db) =>
{ {
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column. // In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
var rows = await db.Feedback.AsNoTracking().ToListAsync(); // auf DateTimeOffset-Spalten übersetzen.
var rows = await db.Feedback.ToListAsync();
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
if (expired.Count > 0)
{
db.Feedback.RemoveRange(expired);
await db.SaveChangesAsync();
rows = rows.Except(expired).ToList();
}
return TypedResults.Ok(rows return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt) .OrderByDescending(f => f.CreatedAt)
.Select(ToDto) .Select(ToDto)
@@ -66,6 +82,10 @@ namespace GerbilManagerWebAPI.Endpoints
if (entity is null) if (entity is null)
return TypedResults.NotFound(); return TypedResults.NotFound();
// Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten.
var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question);
if (input.Message is not null) if (input.Message is not null)
{ {
if (string.IsNullOrWhiteSpace(input.Message)) if (string.IsNullOrWhiteSpace(input.Message))
@@ -133,6 +153,13 @@ namespace GerbilManagerWebAPI.Endpoints
entity.ResolvedAt = resolved entity.ResolvedAt = resolved
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow) ? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
: null; : null;
// Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene
// Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten
// und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in
// ihre Rückfrage zurück und bekommen KEINEN Zeitstempel.
if (!resolved && wasResolved && !hadOpenRueckfrage)
entity.ReopenedAt = DateTimeOffset.UtcNow;
} }
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved). // FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
@@ -155,6 +182,9 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(ToDto(entity)); return TypedResults.Ok(ToDto(entity));
}); });
// SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert
// (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar
// über POST /feedback/{id}/restore.
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> ( group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
Guid id, ApplicationContext db) => Guid id, ApplicationContext db) =>
{ {
@@ -162,11 +192,25 @@ namespace GerbilManagerWebAPI.Endpoints
if (entity is null) if (entity is null)
return TypedResults.NotFound(); return TypedResults.NotFound();
db.Feedback.Remove(entity); entity.DeletedAt ??= DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return TypedResults.NoContent(); return TypedResults.NoContent();
}); });
// WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in
// seinen vorherigen Status (Offen/Rückfrage/…) zurück.
group.MapPost("/{id:guid}/restore", async Task<Results<Ok<FeedbackDto>, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
return TypedResults.NotFound();
entity.DeletedAt = null;
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
return app; return app;
} }
@@ -220,6 +264,6 @@ namespace GerbilManagerWebAPI.Endpoints
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url, 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.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext, f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
DeserializeThread(f.Thread)); DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class FeedbackReopenedAndSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedAt",
table: "Feedback",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ReopenedAt",
table: "Feedback",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Feedback");
migrationBuilder.DropColumn(
name: "ReopenedAt",
table: "Feedback");
}
}
}

View File

@@ -906,6 +906,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName") b.Property<string>("EntityName")
.HasColumnType("text"); .HasColumnType("text");
@@ -925,6 +928,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Question") b.Property<string>("Question")
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTimeOffset?>("ReopenedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ResolvedAt") b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");

View File

@@ -55,6 +55,21 @@ namespace GerbilManagerWebAPI.Models
/// <summary>When the ticket was marked resolved; null while open.</summary> /// <summary>When the ticket was marked resolved; null while open.</summary>
public DateTimeOffset? ResolvedAt { get; set; } public DateTimeOffset? ResolvedAt { get; set; }
/// <summary>
/// When the ticket was reopened FROM a resolved state that had no pending Rückfrage —
/// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that
/// merely had an open question, got marked resolved, and were reopened (those just return
/// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI.
/// </summary>
public DateTimeOffset? ReopenedAt { get; set; }
/// <summary>
/// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are
/// NOT removed from the DB — they move into the "Gelöscht" category and can be restored
/// (DeletedAt → null) from there. null = not deleted.
/// </summary>
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary> /// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
public string? Question { get; set; } public string? Question { get; set; }

View File

@@ -431,6 +431,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
fixNote: null, fixNote: null,
agentContext: null, agentContext: null,
thread: [], thread: [],
reopenedAt: null,
deletedAt: null,
} }
db.feedback.push(created) db.feedback.push(created)
return json(route, 201, created) return json(route, 201, created)
@@ -440,6 +442,18 @@ export async function installMockApi(page: Page): Promise<MockDb> {
} }
return json(route, 405) return json(route, 405)
} }
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
if (restoreMatch) {
const fid = decodeURIComponent(restoreMatch[1])
const row = db.feedback.find((f) => f.id === fid)
if (!row) return json(route, 404, { title: 'Not Found' })
if (method === 'POST') {
row.deletedAt = null
return json(route, 200, row)
}
return json(route, 405)
}
// FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE. // FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE.
const fm = path.match(/^\/feedback\/([^/]+)$/) const fm = path.match(/^\/feedback\/([^/]+)$/)
if (fm) { if (fm) {
@@ -456,6 +470,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
agentContext?: string agentContext?: string
} }
const row = db.feedback[idx] const row = db.feedback[idx]
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
const wasResolved = row.status === 'Resolved'
const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim()
if (typeof body.message === 'string') { if (typeof body.message === 'string') {
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.') if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
row.message = body.message.trim() row.message = body.message.trim()
@@ -504,6 +521,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
? s ? s
: 'Open' : 'Open'
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
// „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage
// wieder geöffnet wird (s. Backend).
if (!resolved && wasResolved && !hadOpenRueckfrage)
row.reopenedAt = new Date().toISOString()
} }
// Changelog (fixNote) — laienverständlich, sichtbar. // Changelog (fixNote) — laienverständlich, sichtbar.
if (typeof body.fixNote === 'string') { if (typeof body.fixNote === 'string') {
@@ -518,7 +539,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, row) return json(route, 200, row)
} }
if (method === 'DELETE') { if (method === 'DELETE') {
db.feedback.splice(idx, 1) // Soft-Delete: in den Papierkorb verschieben (nicht entfernen).
const target = db.feedback[idx]
if (!target.deletedAt) target.deletedAt = new Date().toISOString()
return json(route, 204) return json(route, 204)
} }
return json(route, 405) return json(route, 405)

View File

@@ -188,7 +188,9 @@ test.describe('Meine Tickets', () => {
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible() await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
}) })
test('Ticket löschen', async ({ page }) => { test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
page,
}) => {
await page.goto('/hilfe/tickets') await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed) await selectView(page, tt.filters.closed)
@@ -200,8 +202,50 @@ test.describe('Meine Tickets', () => {
acceptNextDialog(page) acceptNextDialog(page)
await card.getByRole('button', { name: tt.delete }).click() await card.getByRole('button', { name: tt.delete }).click()
// Aus „Geschlossen" verschwunden …
await expect( await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'), page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toHaveCount(0) ).toHaveCount(0)
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
await selectView(page, tt.filters.deleted)
const trashed = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(trashed).toBeVisible()
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
await trashed.getByRole('button', { name: tt.restore }).click()
await expect(
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
).toHaveCount(0)
await selectView(page, tt.filters.closed)
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.reopen }).click()
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
}) })
}) })

View File

@@ -50,6 +50,16 @@ export interface Feedback {
createdAt: string createdAt: string
status: FeedbackStatus status: FeedbackStatus
resolvedAt: string | null resolvedAt: string | null
/**
* Zeitpunkt des Wiederöffnens — gesetzt, wenn ein ECHT gelöstes Ticket (ohne offene
* Rückfrage) wieder geöffnet wurde; null sonst. Wird als „Wieder geöffnet am" angezeigt.
*/
reopenedAt: string | null
/**
* Soft-Delete-Marker: gesetzt, wenn das Ticket über die UI gelöscht wurde (wiederherstellbar).
* Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
*/
deletedAt: string | null
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */ /** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
question: string | null question: string | null
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */ /** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
@@ -106,7 +116,12 @@ export function answerTicket(id: string, answer: string): Promise<Feedback> {
return updateFeedback(id, { answer }) return updateFeedback(id, { answer })
} }
/** Delete a ticket. */ /** Soft-delete a ticket (moves it into the "Gelöscht" category; recoverable via restore). */
export function deleteFeedback(id: string): Promise<void> { export function deleteFeedback(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`) return api.delete(`${RESOURCE}/${id}`)
} }
/** Restore a soft-deleted ticket (DeletedAt → null); it returns to its prior status. */
export function restoreFeedback(id: string): Promise<Feedback> {
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
}

View File

@@ -11,7 +11,7 @@
* Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote). * Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS. * Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
*/ */
import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' import { createContext, Fragment, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
@@ -19,6 +19,7 @@ import {
listFeedback, listFeedback,
updateFeedback, updateFeedback,
deleteFeedback, deleteFeedback,
restoreFeedback,
type FeedbackTicket, type FeedbackTicket,
} from '../api/feedback' } from '../api/feedback'
import { getGerbil } from '../api/gerbils' import { getGerbil } from '../api/gerbils'
@@ -227,19 +228,24 @@ function formatDate(iso: string | null): string {
} }
/** Gefilterte Ansichten (Filter-Chips). */ /** Gefilterte Ansichten (Filter-Chips). */
type TicketView = 'dialog' | 'open' | 'closed' type TicketView = 'dialog' | 'open' | 'closed' | 'deleted'
/** Auto-Fallback-Reihenfolge, wenn die gewünschte Ansicht leer ist — „Gelöscht" NICHT (Papierkorb). */
const FALLBACK_VIEWS: TicketView[] = ['open', 'dialog', 'closed']
/** /**
* Zuordnung Ticket-Status → gefilterte Ansicht. * Zuordnung Ticket → gefilterte Ansicht.
* - „Gelöscht" (deleted): soft-gelöscht (deletedAt gesetzt) — hat Vorrang vor dem Status.
* - „Rückfragen" (dialog): NUR NeedsInfo — wartet auf die Antwort der Züchterin. * - „Rückfragen" (dialog): NUR NeedsInfo — wartet auf die Antwort der Züchterin.
* - „Offen" (open): Open + Answered — von der KI zu erledigen. Sobald die Züchterin * - „Offen" (open): Open + Answered — von der KI zu erledigen. Sobald die Züchterin
* geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen" * geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen"
* (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben. * (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben.
* - „Geschlossen" (closed): Resolved. * - „Geschlossen" (closed): Resolved.
*/ */
function viewOf(status: FeedbackTicket['status']): TicketView { function viewOf(ticket: Pick<FeedbackTicket, 'status' | 'deletedAt'>): TicketView {
if (status === 'Resolved') return 'closed' if (ticket.deletedAt) return 'deleted'
if (status === 'NeedsInfo') return 'dialog' if (ticket.status === 'Resolved') return 'closed'
if (ticket.status === 'NeedsInfo') return 'dialog'
return 'open' return 'open'
} }
@@ -251,13 +257,30 @@ export default function TicketsPage() {
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen. // selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
const [view, setView] = useState<TicketView>('open') const [view, setView] = useState<TicketView>('open')
const [userPicked, setUserPicked] = useState(false) const [userPicked, setUserPicked] = useState(false)
// Scrollposition über das Verlassen/Zurückkehren (z. B. Klick auf einen Tier-Link und dann
// Browser-Zurück) hinweg erhalten. Erst NACH dem Wiederherstellen wieder mitschreiben, damit
// der Sprung an den Seitenanfang beim Re-Mount die gemerkte Position nicht überschreibt.
const scrollRestoredRef = useRef(false)
async function handleToggleStatus(ticket: FeedbackTicket) { async function handleToggleStatus(ticket: FeedbackTicket) {
const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved'
try { try {
await updateFeedback(ticket.id, { status: next }) if (ticket.status === 'Resolved') {
tickets.reload() // Wiederöffnen → zurück in die „Rückfragen" (NeedsInfo). Hatte das Ticket bereits eine
toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast) // offene Rückfrage (z. B. eine manuell gelöste Rückfrage), wird genau diese Frage wieder
// geöffnet (kein neuer Hinweis, kein Zeitstempel). War es dagegen ein echt gelöstes Ticket
// ohne Rückfrage, wird um mehr Infos gebeten — und das Backend stempelt „Wieder geöffnet am".
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
await updateFeedback(ticket.id, {
status: 'NeedsInfo',
question: hadRueckfrage ? ticket.question! : t.reopenRequest,
})
tickets.reload()
toast.success(t.reopenedToast)
} else {
await updateFeedback(ticket.id, { status: 'Resolved' })
tickets.reload()
toast.success(t.resolvedToast)
}
} catch (err) { } catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError) toast.error(err instanceof ApiError ? err.message : t.updateError)
} }
@@ -286,6 +309,16 @@ export default function TicketsPage() {
} }
} }
async function handleRestore(ticket: FeedbackTicket) {
try {
await restoreFeedback(ticket.id)
tickets.reload()
toast.success(t.restoredToast)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.restoreError)
}
}
const rows = tickets.data const rows = tickets.data
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende // Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
@@ -300,8 +333,8 @@ export default function TicketsPage() {
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips). // Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
const counts = useMemo(() => { const counts = useMemo(() => {
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0 } const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0, deleted: 0 }
for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1 for (const ticket of rows ?? []) c[viewOf(ticket)] += 1
return c return c
}, [rows]) }, [rows])
@@ -309,7 +342,7 @@ export default function TicketsPage() {
const focusView = useMemo<TicketView | null>(() => { const focusView = useMemo<TicketView | null>(() => {
if (!focusId || !rows) return null if (!focusId || !rows) return null
const target = rows.find((r) => r.id === focusId) const target = rows.find((r) => r.id === focusId)
return target ? viewOf(target.status) : null return target ? viewOf(target) : null
}, [focusId, rows]) }, [focusId, rows])
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert), // Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
@@ -318,9 +351,9 @@ export default function TicketsPage() {
const activeView: TicketView = const activeView: TicketView =
counts[desiredView] > 0 counts[desiredView] > 0
? desiredView ? desiredView
: (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView : FALLBACK_VIEWS.find((v) => counts[v] > 0) ?? desiredView
const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView) const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView)
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben. // Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
useEffect(() => { useEffect(() => {
@@ -333,6 +366,41 @@ export default function TicketsPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [focusId, visible]) }, [focusId, visible])
// Scrollposition laufend in der Session merken (für die Zurück-Navigation).
const SCROLL_KEY = 'tickets-scroll-y'
useEffect(() => {
const onScroll = () => {
if (!scrollRestoredRef.current) return
try {
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))
} catch {
/* sessionStorage nicht verfügbar — Position dann nicht gemerkt */
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
// Nach dem Laden der Tickets die gemerkte Scrollposition EINMALIG wiederherstellen — außer es
// steht ein ?focus=-Sprung an (der hat Vorrang). Erst danach beginnt das Mitschreiben (s. o.).
useEffect(() => {
if (scrollRestoredRef.current || rows === null) return
if (focusId) {
scrollRestoredRef.current = true
return
}
let y = NaN
try {
y = Number(sessionStorage.getItem(SCROLL_KEY))
} catch {
/* ignorieren */
}
scrollRestoredRef.current = true
if (Number.isFinite(y) && y > 0) {
requestAnimationFrame(() => window.scrollTo(0, y))
}
}, [rows, focusId])
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen, // 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). // damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).
const candidateGerbilIds = useMemo(() => { const candidateGerbilIds = useMemo(() => {
@@ -418,6 +486,7 @@ export default function TicketsPage() {
dialog: t.emptyDialog, dialog: t.emptyDialog,
open: t.emptyOpen, open: t.emptyOpen,
closed: t.emptyClosed, closed: t.emptyClosed,
deleted: t.emptyDeleted,
} }
return ( return (
@@ -437,7 +506,7 @@ export default function TicketsPage() {
) : ( ) : (
<> <>
<div className="tickets-filters" role="group" aria-label={t.title}> <div className="tickets-filters" role="group" aria-label={t.title}>
{(['dialog', 'open', 'closed'] as TicketView[]).map((v) => ( {(['dialog', 'open', 'closed', 'deleted'] as TicketView[]).map((v) => (
<button <button
key={v} key={v}
type="button" type="button"
@@ -467,6 +536,7 @@ export default function TicketsPage() {
onSaveMessage={(message) => handleSaveMessage(ticket, message)} onSaveMessage={(message) => handleSaveMessage(ticket, message)}
onAnswer={(answer) => handleAnswer(ticket, answer)} onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)} onDelete={() => handleDelete(ticket)}
onRestore={() => handleRestore(ticket)}
/> />
))} ))}
</ul> </ul>
@@ -484,6 +554,7 @@ interface TicketCardProps {
onSaveMessage: (message: string) => Promise<void> onSaveMessage: (message: string) => Promise<void>
onAnswer: (answer: string) => Promise<void> onAnswer: (answer: string) => Promise<void>
onDelete: () => void onDelete: () => void
onRestore: () => void
} }
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */ /** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
@@ -500,7 +571,10 @@ function statusBadge(status: FeedbackTicket['status']): { label: string; modifie
} }
} }
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete }: TicketCardProps) { /** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
const TRASH_RETENTION_DAYS = 30
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete, onRestore }: TicketCardProps) {
const toast = useToast() const toast = useToast()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(ticket.message) const [draft, setDraft] = useState(ticket.message)
@@ -526,7 +600,21 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
const [answering, setAnswering] = useState(false) const [answering, setAnswering] = useState(false)
const entityLink = entityHref(ticket) const entityLink = entityHref(ticket)
const resolved = ticket.status === 'Resolved' const resolved = ticket.status === 'Resolved'
const deleted = !!ticket.deletedAt
const badge = statusBadge(ticket.status) const badge = statusBadge(ticket.status)
// Verbleibende Tage bis zur endgültigen Löschung (Soft-Delete-Countdown). `new Date()` (wie
// anderswo im Code) statt Date.now() — letzteres verstößt gegen die Purity-Lintregel.
const trashDaysLeft = ticket.deletedAt
? Math.max(
0,
Math.ceil(
(new Date(ticket.deletedAt).getTime() +
TRASH_RETENTION_DAYS * 86_400_000 -
new Date().getTime()) /
86_400_000,
),
)
: null
const [answerEditing, setAnswerEditing] = useState(false) const [answerEditing, setAnswerEditing] = useState(false)
// Erstantwort: Rückfrage offen, noch keine Antwort. // Erstantwort: Rückfrage offen, noch keine Antwort.
const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved)) const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved))
@@ -534,12 +622,13 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
// Info noch korrigieren, BEVOR die KI das Ticket bearbeitet. // Info noch korrigieren, BEVOR die KI das Ticket bearbeitet.
const canAmend = !!ticket.answer && ticket.status === 'Answered' const canAmend = !!ticket.answer && ticket.status === 'Answered'
// Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf. // Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf.
const showAnswerForm = canAnswerNew || answerEditing || (canAmend && !!answerDraft) // Im Papierkorb (deleted) ist nichts interaktiv außer „Wiederherstellen".
const showAnswerForm = !deleted && (canAnswerNew || answerEditing || (canAmend && !!answerDraft))
// Der ursprüngliche Ticket-Text ist nur bearbeitbar, SOLANGE es noch keine Antwort gibt // Der ursprüngliche Ticket-Text ist nur bearbeitbar, SOLANGE es noch keine Antwort gibt
// UND keine Rückfrage offen ist (NeedsInfo). Sobald die Züchterin geantwortet hat (oder die // UND keine Rückfrage offen ist (NeedsInfo). Sobald die Züchterin geantwortet hat (oder die
// KI nachgefragt hat), bleibt der Erst-Text fix — editierbar ist dann nur noch die neueste // KI nachgefragt hat), bleibt der Erst-Text fix — editierbar ist dann nur noch die neueste
// Antwort (und auch die nur, bis die KI darauf reagiert hat, s. canAmend). // Antwort (und auch die nur, bis die KI darauf reagiert hat, s. canAmend).
const canEditMessage = !ticket.answer && ticket.status !== 'NeedsInfo' const canEditMessage = !deleted && !ticket.answer && ticket.status !== 'NeedsInfo'
function startAmend() { function startAmend() {
if (!answerDraft) setAnswerDraft(ticket.answer ?? '') if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
@@ -725,6 +814,16 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
</div> </div>
)} )}
{deleted && trashDaysLeft !== null && (
<p className="ticket-card__trash-notice">
{trashDaysLeft === 0
? t.trashCountdownToday
: trashDaysLeft === 1
? t.trashCountdownTomorrow
: t.trashCountdownDays.replace('{n}', String(trashDaysLeft))}
</p>
)}
<dl className="ticket-card__meta"> <dl className="ticket-card__meta">
<div> <div>
<dt>{t.createdLabel}</dt> <dt>{t.createdLabel}</dt>
@@ -736,19 +835,39 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
<dd>{formatDate(ticket.resolvedAt)}</dd> <dd>{formatDate(ticket.resolvedAt)}</dd>
</div> </div>
)} )}
{ticket.reopenedAt && (
<div>
<dt>{t.reopenedLabel}</dt>
<dd>{formatDate(ticket.reopenedAt)}</dd>
</div>
)}
{deleted && (
<div>
<dt>{t.deletedLabel}</dt>
<dd>{formatDate(ticket.deletedAt)}</dd>
</div>
)}
</dl> </dl>
<div className="ticket-card__actions"> <div className="ticket-card__actions">
<button type="button" className="btn" onClick={onToggleStatus}> {deleted ? (
{resolved ? t.reopen : t.markResolved} <button type="button" className="btn btn--primary" onClick={onRestore}>
</button> {t.restore}
{canEditMessage && (
<button type="button" className="btn" onClick={startEdit}>
{t.edit}
</button> </button>
) : (
<>
<button type="button" className="btn" onClick={onToggleStatus}>
{resolved ? t.reopen : t.markResolved}
</button>
{canEditMessage && (
<button type="button" className="btn" onClick={startEdit}>
{t.edit}
</button>
)}
<button type="button" className="btn btn--danger" onClick={onDelete}>
{t.delete}
</button>
</>
)} )}
<button type="button" className="btn btn--danger" onClick={onDelete}>
{t.delete}
</button>
</div> </div>
</> </>
)} )}

View File

@@ -311,6 +311,18 @@
word-break: break-word; word-break: break-word;
} }
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
.ticket-card__trash-notice {
border-left: 3px solid var(--color-danger, #ef4444);
background: var(--color-danger-bg, #fef2f2);
color: var(--color-danger-text, #991b1b);
padding: 0.55rem 0.75rem;
border-radius: 0 8px 8px 0;
margin: 0 0 0.85rem;
font-size: 0.9rem;
font-weight: 600;
}
.ticket-card__meta { .ticket-card__meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -1221,20 +1221,31 @@ export const de = {
entityLabel: 'Datensatz', entityLabel: 'Datensatz',
createdLabel: 'Gesendet am', createdLabel: 'Gesendet am',
resolvedLabel: 'Gelöst am', resolvedLabel: 'Gelöst am',
reopenedLabel: 'Wieder geöffnet am',
markResolved: 'Als gelöst markieren', markResolved: 'Als gelöst markieren',
reopen: 'Wieder öffnen', reopen: 'Wieder öffnen',
reopenRequest:
'Du hast dieses Ticket wieder geöffnet. Bitte beschreibe kurz, was noch fehlt oder nicht stimmt — dann schaue ich es mir erneut an.',
edit: 'Bearbeiten', edit: 'Bearbeiten',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Wird gespeichert …', saving: 'Wird gespeichert …',
delete: 'Löschen', delete: 'Löschen',
confirmDelete: 'Diesen Fehlerbericht wirklich löschen?', confirmDelete: 'Dieses Ticket in den Papierkorb verschieben? Du kannst es unter „Gelöscht" wiederherstellen.',
editTitle: 'Fehlerbericht bearbeiten', editTitle: 'Fehlerbericht bearbeiten',
editLabel: 'Beschreibung', editLabel: 'Beschreibung',
saved: 'Änderung gespeichert.', saved: 'Änderung gespeichert.',
resolvedToast: 'Ticket als gelöst markiert.', resolvedToast: 'Ticket als gelöst markiert.',
reopenedToast: 'Ticket wieder geöffnet.', reopenedToast: 'Ticket wieder geöffnet.',
deletedToast: 'Ticket gelöscht.', deletedToast: 'Ticket in den Papierkorb verschoben.',
restore: 'Wiederherstellen',
restoredToast: 'Ticket wiederhergestellt.',
restoreError: 'Ticket konnte nicht wiederhergestellt werden.',
/** Countdown im Papierkorb bis zur endgültigen Löschung. {n} = verbleibende Tage. */
trashCountdownDays: 'Wird in {n} Tagen endgültig gelöscht.',
trashCountdownTomorrow: 'Wird morgen endgültig gelöscht.',
trashCountdownToday: 'Wird heute endgültig gelöscht.',
deletedLabel: 'Gelöscht am',
updateError: 'Ticket konnte nicht aktualisiert werden.', updateError: 'Ticket konnte nicht aktualisiert werden.',
deleteError: 'Ticket konnte nicht gelöscht werden.', deleteError: 'Ticket konnte nicht gelöscht werden.',
emptyMessage: 'Bitte beschreibe den Fehler.', emptyMessage: 'Bitte beschreibe den Fehler.',
@@ -1257,11 +1268,14 @@ export const de = {
open: 'Offen', open: 'Offen',
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */ /** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
closed: 'Geschlossen', closed: 'Geschlossen',
/** Gruppe „Gelöscht": in den Papierkorb verschobene Tickets (wiederherstellbar). */
deleted: 'Gelöscht',
}, },
/** Leertext je gefilterter Ansicht. */ /** Leertext je gefilterter Ansicht. */
emptyDialog: 'Aktuell keine Tickets im Dialog.', emptyDialog: 'Aktuell keine Tickets im Dialog.',
emptyOpen: 'Keine offenen Tickets.', emptyOpen: 'Keine offenen Tickets.',
emptyClosed: 'Noch keine geschlossenen Tickets.', emptyClosed: 'Noch keine geschlossenen Tickets.',
emptyDeleted: 'Der Papierkorb ist leer.',
/** Changelog (fixNote) auf geschlossenen Tickets. */ /** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert', changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */ /** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */