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:
2026-06-22 19:52:34 +02:00
parent f1c1069821
commit 5069c0eb1e
14 changed files with 2027 additions and 26 deletions

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

@@ -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; }
}
}

View File

@@ -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)

View File

@@ -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,
},
],
}

View File

@@ -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')

View File

@@ -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}`)

View File

@@ -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>

View File

@@ -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;

View File

@@ -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). */