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

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