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