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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user