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:
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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`, {})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user