feat(tickets): Volltextsuche + Kategorien, 👍/👎-Rückmeldung & ähnliche gelöste Tickets

- Suche: Volltext über Nachricht/Frage/Antwort/Changelog/Name/Kategorie, plus
  Kategorie-Filter (KI-gesetzte Kategorie als Chip + Auswahlmenü), je innerhalb der
  aktiven Ansicht.
- 👍/👎 auf gelösten Tickets: 👍 speichert die Rückmeldung, 👎 öffnet das Ticket
  wieder (zurück in die Rückfragen) und bittet um die fehlende Info.
- Melde-Fenster: schlägt beim Tippen ähnliche bereits gelöste Tickets vor
  (Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt) — Self-Service.

Backend: Feedback.Category + Feedback.Helpful (+ PUT-Handling), Migration
FeedbackCategoryAndHelpful (beide nullable). Tests: 260 Backend grün, e2e Tickets
Desktop +4 (Suche/Kategorie/👍/👎), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:14:07 +02:00
parent c0a7b1a5ee
commit 48b138bbfd
15 changed files with 2217 additions and 10 deletions

View File

@@ -319,6 +319,29 @@ export default function TicketsPage() {
}
}
// 👍/👎 auf einem gelösten Ticket. 👍 = nur Rückmeldung speichern; 👎 = Ticket wieder öffnen
// (zurück in die Rückfragen) und um die fehlende Info bitten.
async function handleHelpful(ticket: FeedbackTicket, helpful: boolean) {
try {
if (helpful) {
await updateFeedback(ticket.id, { helpful: true })
tickets.reload()
toast.success(t.helpfulThanks)
} else {
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
await updateFeedback(ticket.id, {
helpful: false,
status: 'NeedsInfo',
question: hadRueckfrage ? ticket.question! : t.helpfulReopenNote,
})
tickets.reload()
toast.success(t.reopenedToast)
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError)
}
}
const rows = tickets.data
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
@@ -355,6 +378,29 @@ export default function TicketsPage() {
const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView)
// Suche (Volltext über Nachricht/Frage/Antwort/Name/Kategorie) + Kategorie-Filter, jeweils
// INNERHALB der aktiven Ansicht.
const [query, setQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
// Vorhandene Kategorien (über alle nicht-gelöschten Tickets) für das Auswahlmenü.
const categories = useMemo(() => {
const set = new Set<string>()
for (const tk of rows ?? []) if (!tk.deletedAt && tk.category) set.add(tk.category)
return [...set].sort((a, b) => a.localeCompare(b, 'de'))
}, [rows])
const displayed = useMemo(() => {
const q = query.trim().toLowerCase()
return visible.filter((tk) => {
if (categoryFilter && tk.category !== categoryFilter) return false
if (!q) return true
const hay = [tk.message, tk.question, tk.answer, tk.fixNote, tk.entityName, tk.category]
.filter(Boolean)
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [visible, query, categoryFilter])
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
useEffect(() => {
if (!focusId) return
@@ -364,7 +410,7 @@ export default function TicketsPage() {
el.classList.add('ticket-card--focus')
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
return () => clearTimeout(timer)
}, [focusId, visible])
}, [focusId, displayed])
// Scrollposition laufend in der Session merken (für die Zurück-Navigation).
const SCROLL_KEY = 'tickets-scroll-y'
@@ -523,12 +569,38 @@ export default function TicketsPage() {
))}
</div>
{visible.length === 0 ? (
<p className="tickets-empty">{emptyText[activeView]}</p>
<div className="tickets-search">
<input
type="search"
className="tickets-search__input"
placeholder={t.searchPlaceholder}
aria-label={t.searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{categories.length > 0 && (
<select
className="tickets-search__category"
aria-label={t.categoryFilterLabel}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">{t.categoryAll}</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
</div>
{displayed.length === 0 ? (
<p className="tickets-empty">{query || categoryFilter ? t.emptySearch : emptyText[activeView]}</p>
) : (
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
<ul className="tickets-list">
{visible.map((ticket) => (
{displayed.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
@@ -537,6 +609,7 @@ export default function TicketsPage() {
onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)}
onRestore={() => handleRestore(ticket)}
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
/>
))}
</ul>
@@ -555,6 +628,7 @@ interface TicketCardProps {
onAnswer: (answer: string) => Promise<void>
onDelete: () => void
onRestore: () => void
onHelpful: (helpful: boolean) => void
}
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
@@ -574,7 +648,15 @@ function statusBadge(status: FeedbackTicket['status']): { label: string; modifie
/** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
const TRASH_RETENTION_DAYS = 30
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete, onRestore }: TicketCardProps) {
function TicketCard({
ticket,
onToggleStatus,
onSaveMessage,
onAnswer,
onDelete,
onRestore,
onHelpful,
}: TicketCardProps) {
const toast = useToast()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(ticket.message)
@@ -684,6 +766,7 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete,
<div className="ticket-card__top">
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
{ticket.category && <span className="ticket-card__category">{ticket.category}</span>}
{ticket.entityName &&
(entityLink ? (
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
@@ -814,6 +897,24 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete,
</div>
)}
{resolved && !deleted && (
<div className="ticket-card__helpful">
{ticket.helpful === true ? (
<span className="ticket-card__helpful-thanks">{t.helpfulThanks}</span>
) : (
<>
<span className="ticket-card__helpful-q">{t.helpfulQuestion}</span>
<button type="button" className="btn btn--small" onClick={() => onHelpful(true)}>
{t.helpfulYes}
</button>
<button type="button" className="btn btn--small" onClick={() => onHelpful(false)}>
{t.helpfulNo}
</button>
</>
)}
</div>
)}
{deleted && trashDaysLeft !== null && (
<p className="ticket-card__trash-notice">
{trashDaysLeft === 0