feat(tickets): Rückfrage am Ticket + Antwort durch die Züchterin
Tickets können jetzt eine Rückfrage (Question) tragen und die Züchterin kann
in-app antworten. Feedback um FK-freie Felder Question/Answer/AnsweredAt erweitert
(überlebt Ingest-Wipe); Status-Lebenszyklus Open → NeedsInfo (Rückfrage gestellt)
→ Answered (beantwortet) → Resolved. Migration AddFeedbackQuestionAnswer.
PUT /feedback/{id}: question → NeedsInfo, answer → Answered+AnsweredAt; DTO gibt
die Felder zurück. Tickets-Seite (/hilfe/tickets) zeigt die Rückfrage hervorgehoben
und bietet ein Antwort-Feld + „Antworten"; Status-Badges Offen/Rückfrage offen/
Beantwortet/Gelöst.
Tests: FeedbackEndpointTests (Frage→NeedsInfo, Antwort→Answered, übersteht Ingest),
e2e tickets.spec.ts. dotnet/vitest/playwright grün.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,54 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Create an open ticket.
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Wurf B hat ein falsches Geburtsdatum.",
|
||||
context = "litter-detail",
|
||||
entityName = "Wurf B",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet.
|
||||
var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" });
|
||||
Assert.Equal(HttpStatusCode.OK, ask.StatusCode);
|
||||
var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString());
|
||||
Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind);
|
||||
|
||||
// Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained.
|
||||
var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." });
|
||||
Assert.Equal(HttpStatusCode.OK, reply.StatusCode);
|
||||
var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Answered", answered.GetProperty("status").GetString());
|
||||
Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString());
|
||||
Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_question_on_resolved_ticket_keeps_it_resolved()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" });
|
||||
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_and_delete_unknown_id_return_404()
|
||||
{
|
||||
@@ -178,8 +226,11 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
EntityName = "Papa",
|
||||
Url = "http://localhost/rennmaeuse/papa",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Resolved",
|
||||
ResolvedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Answered",
|
||||
ResolvedAt = null,
|
||||
Question = "Welches Datum stimmt?",
|
||||
Answer = "Der 2. Juni.",
|
||||
AnsweredAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
|
||||
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||
@@ -209,8 +260,11 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(litterId, survivor.LitterId);
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
Assert.Equal("Resolved", survivor.Status); // ticket status column survives the wipe
|
||||
Assert.NotNull(survivor.ResolvedAt);
|
||||
Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe
|
||||
// The new question/answer columns survive the wipe too.
|
||||
Assert.Equal("Welches Datum stimmt?", survivor.Question);
|
||||
Assert.Equal("Der 2. Juni.", survivor.Answer);
|
||||
Assert.NotNull(survivor.AnsweredAt);
|
||||
|
||||
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||
|
||||
@@ -25,10 +25,18 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? UserAgent,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Status,
|
||||
DateTimeOffset? ResolvedAt);
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? AnsweredAt);
|
||||
|
||||
/// <summary>FEEDBACK: payload for PUT /feedback/{id} (edit message and/or toggle status).</summary>
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
/// attach a clarifying question (Rückfrage), or submit the breeder's answer.
|
||||
/// </summary>
|
||||
public record FeedbackUpdate(
|
||||
string? Message,
|
||||
string? Status);
|
||||
string? Status,
|
||||
string? Question,
|
||||
string? Answer);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets").
|
||||
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
||||
/// GET /feedback -> list reports, newest first (for the ticket list).
|
||||
/// PUT /feedback/{id} -> edit the message and/or toggle status Open/Resolved (sets/clears ResolvedAt).
|
||||
/// PUT /feedback/{id} -> edit message, toggle status, attach a clarifying question (Rückfrage),
|
||||
/// or submit the breeder's answer. Question -> NeedsInfo; Answer -> Answered + AnsweredAt.
|
||||
/// DELETE /feedback/{id} -> remove a report. 404 on missing id.
|
||||
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
|
||||
/// rows survive the import re-ingest wipe.
|
||||
@@ -71,11 +72,53 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.Message = input.Message.Trim();
|
||||
}
|
||||
|
||||
// Attach a clarifying question (Rückfrage). A non-empty question moves the ticket to
|
||||
// NeedsInfo (waiting on the breeder) unless it is already Resolved. A blank/whitespace
|
||||
// question clears it.
|
||||
if (input.Question is not null)
|
||||
{
|
||||
var q = input.Question.Trim();
|
||||
entity.Question = q.Length == 0 ? null : q;
|
||||
if (entity.Question is not null && !entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entity.Status = "NeedsInfo";
|
||||
entity.ResolvedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
// The breeder's answer. A non-empty answer stamps AnsweredAt and moves to Answered.
|
||||
if (input.Answer is not null)
|
||||
{
|
||||
var a = input.Answer.Trim();
|
||||
if (a.Length == 0)
|
||||
{
|
||||
entity.Answer = null;
|
||||
entity.AnsweredAt = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Answer = a;
|
||||
entity.AnsweredAt = DateTimeOffset.UtcNow;
|
||||
entity.Status = "Answered";
|
||||
entity.ResolvedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.Status is not null)
|
||||
{
|
||||
// Normalize to the two known states; resolving stamps ResolvedAt, reopening clears it.
|
||||
var resolved = input.Status.Trim().Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||
entity.Status = resolved ? "Resolved" : "Open";
|
||||
// Normalize to a known lifecycle state. Resolving stamps ResolvedAt; any other
|
||||
// state clears it. Open/NeedsInfo/Answered/Resolved are accepted (case-insensitive),
|
||||
// anything else falls back to Open.
|
||||
var status = input.Status.Trim();
|
||||
var resolved = status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||
var normalized = status switch
|
||||
{
|
||||
_ when status.Equals("Resolved", StringComparison.OrdinalIgnoreCase) => "Resolved",
|
||||
_ when status.Equals("NeedsInfo", StringComparison.OrdinalIgnoreCase) => "NeedsInfo",
|
||||
_ when status.Equals("Answered", StringComparison.OrdinalIgnoreCase) => "Answered",
|
||||
_ => "Open",
|
||||
};
|
||||
entity.Status = normalized;
|
||||
entity.ResolvedAt = resolved
|
||||
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
||||
: null;
|
||||
@@ -102,6 +145,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static FeedbackDto ToDto(Feedback f) =>
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt);
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||
f.Question, f.Answer, f.AnsweredAt);
|
||||
}
|
||||
}
|
||||
|
||||
1564
GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.Designer.cs
generated
Normal file
1564
GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackQuestionAnswer : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Answer",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "AnsweredAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Question",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Answer",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AnsweredAt",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Question",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -802,6 +802,12 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Answer")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("AnsweredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -828,6 +834,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Question")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
||||
@@ -45,12 +45,23 @@ namespace GerbilManagerWebAPI.Models
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ticket lifecycle status: "Open" | "Resolved" (default "Open"). Plain string,
|
||||
/// no FK — keeps feedback decoupled and ingest-surviving like the rest of the row.
|
||||
/// Ticket lifecycle status: "Open" | "NeedsInfo" | "Answered" | "Resolved" (default "Open").
|
||||
/// "NeedsInfo" = a maintainer attached a clarifying question (Rückfrage) and is waiting on
|
||||
/// the breeder; "Answered" = the breeder replied. Plain string, no FK — keeps feedback
|
||||
/// decoupled and ingest-surviving like the rest of the row.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "Open";
|
||||
|
||||
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
|
||||
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
|
||||
public string? Question { get; set; }
|
||||
|
||||
/// <summary>The breeder's (Züchterin) reply to the clarifying question; null until answered.</summary>
|
||||
public string? Answer { get; set; }
|
||||
|
||||
/// <summary>When the breeder answered the clarifying question; null until answered.</summary>
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +409,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'Open',
|
||||
resolvedAt: null,
|
||||
question: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
@@ -425,15 +428,47 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
const idx = db.feedback.findIndex((f) => f.id === fid)
|
||||
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { message?: string; status?: string }
|
||||
const body = request.postDataJSON() as {
|
||||
message?: string
|
||||
status?: string
|
||||
question?: string
|
||||
answer?: string
|
||||
}
|
||||
const row = db.feedback[idx]
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
}
|
||||
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved).
|
||||
if (typeof body.question === 'string') {
|
||||
const q = body.question.trim()
|
||||
row.question = q.length === 0 ? null : q
|
||||
if (row.question && row.status !== 'Resolved') {
|
||||
row.status = 'NeedsInfo'
|
||||
row.resolvedAt = null
|
||||
}
|
||||
}
|
||||
// Antwort der Züchterin → Answered + answeredAt.
|
||||
if (typeof body.answer === 'string') {
|
||||
const a = body.answer.trim()
|
||||
if (a.length === 0) {
|
||||
row.answer = null
|
||||
row.answeredAt = null
|
||||
} else {
|
||||
row.answer = a
|
||||
row.answeredAt = new Date().toISOString()
|
||||
row.status = 'Answered'
|
||||
row.resolvedAt = null
|
||||
}
|
||||
}
|
||||
if (typeof body.status === 'string') {
|
||||
const resolved = body.status.toLowerCase() === 'resolved'
|
||||
row.status = resolved ? 'Resolved' : 'Open'
|
||||
const s = body.status
|
||||
const resolved = s.toLowerCase() === 'resolved'
|
||||
row.status = resolved
|
||||
? 'Resolved'
|
||||
: s === 'NeedsInfo' || s === 'Answered'
|
||||
? s
|
||||
: 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
}
|
||||
return json(route, 200, row)
|
||||
|
||||
@@ -408,6 +408,28 @@ export function seedDb(): MockDb {
|
||||
createdAt: '2026-06-10T09:00:00Z',
|
||||
status: 'Resolved',
|
||||
resolvedAt: '2026-06-12T14:00:00Z',
|
||||
question: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
// Ticket mit offener Rückfrage (NeedsInfo): die Züchterin soll hier antworten können.
|
||||
id: 'feedback-needsinfo',
|
||||
message: 'Der Wurf B hat ein falsches Geburtsdatum.',
|
||||
context: 'litter-detail',
|
||||
gerbilId: null,
|
||||
litterId: 'wurf-b',
|
||||
contactId: null,
|
||||
entityName: 'Wurf B',
|
||||
url: 'http://localhost:5173/wuerfe/wurf-b',
|
||||
clientTimestamp: '2026-06-14T08:00:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-14T08:00:00Z',
|
||||
status: 'NeedsInfo',
|
||||
resolvedAt: null,
|
||||
question: 'Welches Geburtsdatum ist korrekt — der 1. oder der 2. Juni?',
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'feedback-open',
|
||||
@@ -423,6 +445,9 @@ export function seedDb(): MockDb {
|
||||
createdAt: '2026-06-15T11:30:00Z',
|
||||
status: 'Open',
|
||||
resolvedAt: null,
|
||||
question: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -57,6 +57,28 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Rückfrage ist sichtbar; Status-Badge „Rückfrage offen".
|
||||
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible()
|
||||
await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible()
|
||||
|
||||
// Antworten.
|
||||
await card.locator('textarea').fill('Korrekt ist der 2. Juni.')
|
||||
await card.getByRole('button', { name: tt.answerButton }).click()
|
||||
|
||||
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet".
|
||||
await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible()
|
||||
await expect(card.locator('.ticket-badge--answered')).toBeVisible()
|
||||
await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
|
||||
@@ -18,8 +18,14 @@ export interface FeedbackInput {
|
||||
clientTimestamp?: string | null
|
||||
}
|
||||
|
||||
/** Ticket lifecycle status (matches the backend Status contract). */
|
||||
export type FeedbackStatus = 'Open' | 'Resolved'
|
||||
/**
|
||||
* Ticket lifecycle status (matches the backend Status contract).
|
||||
* - Open — neu, noch keine Rückfrage.
|
||||
* - NeedsInfo — eine Rückfrage wurde gestellt, wartet auf die Antwort der Züchterin.
|
||||
* - Answered — die Züchterin hat geantwortet.
|
||||
* - Resolved — erledigt.
|
||||
*/
|
||||
export type FeedbackStatus = 'Open' | 'NeedsInfo' | 'Answered' | 'Resolved'
|
||||
|
||||
export interface Feedback {
|
||||
id: string
|
||||
@@ -35,15 +41,28 @@ export interface Feedback {
|
||||
createdAt: string
|
||||
status: FeedbackStatus
|
||||
resolvedAt: string | null
|
||||
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
|
||||
question: string | null
|
||||
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
|
||||
answer: string | null
|
||||
/** Zeitpunkt der Antwort der Züchterin. */
|
||||
answeredAt: string | null
|
||||
}
|
||||
|
||||
/** Alias used by the "Meine Tickets" area for readability. */
|
||||
export type FeedbackTicket = Feedback
|
||||
|
||||
/** Payload for PUT /feedback/{id}: edit the message and/or toggle the status. */
|
||||
/**
|
||||
* Payload for PUT /feedback/{id}: edit the message, toggle the status, attach a
|
||||
* clarifying question (Rückfrage) or submit the breeder's answer.
|
||||
*/
|
||||
export interface FeedbackUpdate {
|
||||
message?: string
|
||||
status?: FeedbackStatus
|
||||
/** Rückfrage anhängen (nicht leer ⇒ Status wird serverseitig auf NeedsInfo gesetzt). */
|
||||
question?: string
|
||||
/** Antwort der Züchterin (nicht leer ⇒ Status Answered + answeredAt). */
|
||||
answer?: string
|
||||
}
|
||||
|
||||
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
|
||||
@@ -60,6 +79,11 @@ export function updateFeedback(id: string, body: FeedbackUpdate): Promise<Feedba
|
||||
return api.put<Feedback>(`${RESOURCE}/${id}`, body)
|
||||
}
|
||||
|
||||
/** Convenience: submit the breeder's answer to a ticket's clarifying question. */
|
||||
export function answerTicket(id: string, answer: string): Promise<Feedback> {
|
||||
return updateFeedback(id, { answer })
|
||||
}
|
||||
|
||||
/** Delete a ticket. */
|
||||
export function deleteFeedback(id: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/${id}`)
|
||||
|
||||
@@ -57,6 +57,12 @@ export default function TicketsPage() {
|
||||
toast.success(t.saved)
|
||||
}
|
||||
|
||||
async function handleAnswer(ticket: FeedbackTicket, answer: string) {
|
||||
await updateFeedback(ticket.id, { answer })
|
||||
tickets.reload()
|
||||
toast.success(t.answeredToast)
|
||||
}
|
||||
|
||||
async function handleDelete(ticket: FeedbackTicket) {
|
||||
if (!window.confirm(t.confirmDelete)) return
|
||||
try {
|
||||
@@ -92,6 +98,7 @@ export default function TicketsPage() {
|
||||
ticket={ticket}
|
||||
onToggleStatus={() => handleToggleStatus(ticket)}
|
||||
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
||||
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
||||
onDelete={() => handleDelete(ticket)}
|
||||
/>
|
||||
))}
|
||||
@@ -105,15 +112,36 @@ interface TicketCardProps {
|
||||
ticket: FeedbackTicket
|
||||
onToggleStatus: () => void
|
||||
onSaveMessage: (message: string) => Promise<void>
|
||||
onAnswer: (answer: string) => Promise<void>
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketCardProps) {
|
||||
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
|
||||
function statusBadge(status: FeedbackTicket['status']): { label: string; modifier: string } {
|
||||
switch (status) {
|
||||
case 'Resolved':
|
||||
return { label: `✓ ${t.statusResolved}`, modifier: 'ticket-badge--resolved' }
|
||||
case 'NeedsInfo':
|
||||
return { label: t.statusNeedsInfo, modifier: 'ticket-badge--needsinfo' }
|
||||
case 'Answered':
|
||||
return { label: t.statusAnswered, modifier: 'ticket-badge--answered' }
|
||||
default:
|
||||
return { label: t.statusOpen, modifier: 'ticket-badge--open' }
|
||||
}
|
||||
}
|
||||
|
||||
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete }: TicketCardProps) {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(ticket.message)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [answerDraft, setAnswerDraft] = useState('')
|
||||
const [answering, setAnswering] = useState(false)
|
||||
const resolved = ticket.status === 'Resolved'
|
||||
const badge = statusBadge(ticket.status)
|
||||
// Die Züchterin darf antworten, solange eine Rückfrage offen ist und noch keine
|
||||
// Antwort vorliegt (NeedsInfo, oder eine Rückfrage ohne Antwort).
|
||||
const canAnswer = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved))
|
||||
|
||||
function startEdit() {
|
||||
setDraft(ticket.message)
|
||||
@@ -137,14 +165,27 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketC
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAnswer() {
|
||||
const trimmed = answerDraft.trim()
|
||||
if (!trimmed) {
|
||||
toast.error(t.emptyAnswer)
|
||||
return
|
||||
}
|
||||
setAnswering(true)
|
||||
try {
|
||||
await onAnswer(trimmed)
|
||||
setAnswerDraft('')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.updateError)
|
||||
} finally {
|
||||
setAnswering(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`ticket-card${resolved ? ' ticket-card--resolved' : ''}`}>
|
||||
<div className="ticket-card__top">
|
||||
<span
|
||||
className={`ticket-badge ${resolved ? 'ticket-badge--resolved' : 'ticket-badge--open'}`}
|
||||
>
|
||||
{resolved ? `✓ ${t.statusResolved}` : t.statusOpen}
|
||||
</span>
|
||||
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
|
||||
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
|
||||
{ticket.entityName && <span className="ticket-card__entity">{ticket.entityName}</span>}
|
||||
</div>
|
||||
@@ -174,6 +215,50 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketC
|
||||
) : (
|
||||
<>
|
||||
<p className="ticket-card__message">{ticket.message}</p>
|
||||
|
||||
{ticket.question && (
|
||||
<div className="ticket-card__question">
|
||||
<span className="ticket-card__question-label">{t.questionLabel}</span>
|
||||
<p className="ticket-card__question-text">{ticket.question}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.answer ? (
|
||||
<div className="ticket-card__answer">
|
||||
<span className="ticket-card__answer-label">{t.answerLabel}</span>
|
||||
<p className="ticket-card__answer-text">{ticket.answer}</p>
|
||||
{ticket.answeredAt && (
|
||||
<span className="ticket-card__answer-date">
|
||||
{t.answeredOn}: {formatDate(ticket.answeredAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : canAnswer ? (
|
||||
<div className="ticket-card__answer-form">
|
||||
<label className="ticket-card__edit-label" htmlFor={`ticket-answer-${ticket.id}`}>
|
||||
{t.answerLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id={`ticket-answer-${ticket.id}`}
|
||||
className="ticket-card__textarea"
|
||||
value={answerDraft}
|
||||
onChange={(e) => setAnswerDraft(e.target.value)}
|
||||
placeholder={t.answerPlaceholder}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="ticket-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={submitAnswer}
|
||||
disabled={answering}
|
||||
>
|
||||
{answering ? t.answering : t.answerButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<dl className="ticket-card__meta">
|
||||
<div>
|
||||
<dt>{t.createdLabel}</dt>
|
||||
|
||||
@@ -87,6 +87,18 @@
|
||||
color: var(--color-success-text, #166534);
|
||||
}
|
||||
|
||||
/* NeedsInfo: Rückfrage offen — Aufmerksamkeit (Bernstein). */
|
||||
.ticket-badge--needsinfo {
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-text, #92400e);
|
||||
}
|
||||
|
||||
/* Answered: Züchterin hat geantwortet — blau. */
|
||||
.ticket-badge--answered {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-text, #1e40af);
|
||||
}
|
||||
|
||||
.ticket-card__context {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -113,6 +125,54 @@
|
||||
color: var(--color-muted, #555);
|
||||
}
|
||||
|
||||
/* RÜCKFRAGE (Question): prominent hervorgehoben mit Bernstein-Akzent. */
|
||||
.ticket-card__question {
|
||||
border-left: 3px solid var(--color-warning, #f59e0b);
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-card__question-label,
|
||||
.ticket-card__answer-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-muted, #777);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ticket-card__question-text,
|
||||
.ticket-card__answer-text {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ANTWORT der Züchterin: blauer Akzent. */
|
||||
.ticket-card__answer {
|
||||
border-left: 3px solid var(--color-accent, #2563eb);
|
||||
background: var(--color-info-bg, #eff6ff);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-card__answer-date {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted, #777);
|
||||
}
|
||||
|
||||
.ticket-card__answer-form {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -892,6 +892,8 @@ export const de = {
|
||||
loadError: 'Tickets konnten nicht geladen werden.',
|
||||
statusOpen: 'Offen',
|
||||
statusResolved: 'Gelöst',
|
||||
statusNeedsInfo: 'Rückfrage offen',
|
||||
statusAnswered: 'Beantwortet',
|
||||
/** Spalte/Label für die Ansicht, aus der der Bericht kam. */
|
||||
contextLabel: 'Ansicht',
|
||||
entityLabel: 'Datensatz',
|
||||
@@ -914,6 +916,15 @@ export const de = {
|
||||
updateError: 'Ticket konnte nicht aktualisiert werden.',
|
||||
deleteError: 'Ticket konnte nicht gelöscht werden.',
|
||||
emptyMessage: 'Bitte beschreibe den Fehler.',
|
||||
/** Rückfrage (Question) + Antwort der Züchterin (Answer). */
|
||||
questionLabel: 'Rückfrage',
|
||||
answerLabel: 'Deine Antwort',
|
||||
answerPlaceholder: 'Antwort auf die Rückfrage eingeben …',
|
||||
answerButton: 'Antworten',
|
||||
answering: 'Wird gesendet …',
|
||||
answeredOn: 'Beantwortet am',
|
||||
answeredToast: 'Antwort gesendet.',
|
||||
emptyAnswer: 'Bitte gib eine Antwort ein.',
|
||||
},
|
||||
},
|
||||
/** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */
|
||||
|
||||
Reference in New Issue
Block a user