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

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