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

@@ -60,6 +60,10 @@ export interface Feedback {
* Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
*/
deletedAt: string | null
/** Optionale Kategorie/Thema (von der KI gesetzt), z. B. „Genetik", „Import". null = ohne. */
category: string | null
/** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */
helpful: boolean | 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). */
@@ -95,6 +99,10 @@ export interface FeedbackUpdate {
fixNote?: string
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
agentContext?: string
/** Kategorie/Thema setzen (leer ⇒ löschen). */
category?: string
/** 👍/👎-Rückmeldung auf ein gelöstes Ticket. */
helpful?: boolean
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {

View File

@@ -5,13 +5,19 @@
* dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { submitFeedback, type FeedbackContext } from '../api/feedback'
import { listFeedback, submitFeedback, type FeedbackContext, type FeedbackTicket } from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
/** Wörter (≥3 Zeichen) für einen einfachen Ähnlichkeits-Score. */
function wordSet(s: string): Set<string> {
return new Set((s.toLowerCase().match(/[a-zäöüß0-9]{3,}/g) ?? []))
}
export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
@@ -60,6 +66,46 @@ function ReportErrorDialogBody({
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen.
const [resolvedTickets, setResolvedTickets] = useState<FeedbackTicket[]>([])
useEffect(() => {
let cancelled = false
listFeedback()
.then((all) => {
if (!cancelled)
setResolvedTickets(all.filter((tk) => tk.status === 'Resolved' && !tk.deletedAt))
})
.catch(() => {
/* ohne Vorschläge weiter */
})
return () => {
cancelled = true
}
}, [])
// Ähnliche gelöste Tickets: Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt.
const similar = useMemo(() => {
const msgWords = wordSet(message)
if (msgWords.size < 2) return []
const scored = resolvedTickets.map((tk) => {
const hay = wordSet(`${tk.message} ${tk.fixNote ?? ''} ${tk.entityName ?? ''}`)
let score = 0
for (const w of msgWords) if (hay.has(w)) score++
if (
(context.gerbilId && tk.gerbilId === context.gerbilId) ||
(context.litterId && tk.litterId === context.litterId) ||
(context.contactId && tk.contactId === context.contactId)
)
score += 2
return { tk, score }
})
return scored
.filter((s) => s.score >= 2)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((s) => s.tk)
}, [message, resolvedTickets, context.gerbilId, context.litterId, context.contactId])
// Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
useEffect(() => {
try {
@@ -161,6 +207,21 @@ function ReportErrorDialogBody({
rows={5}
/>
{similar.length > 0 && (
<div className="report-error__similar">
<div className="report-error__similar-title">{t.similarTitle}</div>
<ul className="report-error__similar-list">
{similar.map((tk) => (
<li key={tk.id}>
<Link to={`/hilfe/tickets?focus=${tk.id}`} onClick={onClose}>
{tk.message.length > 80 ? `${tk.message.slice(0, 80)}` : tk.message}
</Link>
</li>
))}
</ul>
</div>
)}
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">

View File

@@ -147,3 +147,24 @@
gap: 0.5rem;
margin-top: 1.1rem;
}
/* Vorschlag: ähnliche bereits gelöste Tickets. */
.report-error__similar {
margin-top: 0.85rem;
padding: 0.6rem 0.75rem;
background: var(--color-accent-bg, #eef2ff);
border-radius: 8px;
}
.report-error__similar-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.report-error__similar-list {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}

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

View File

@@ -311,6 +311,60 @@
word-break: break-word;
}
/* Suche + Kategorie-Filter über der Ticket-Liste. */
.tickets-search {
display: flex;
gap: 0.5rem;
margin: 0 0 1rem;
flex-wrap: wrap;
}
.tickets-search__input {
flex: 1 1 16rem;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
font-size: 0.95rem;
}
.tickets-search__category {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
font-size: 0.95rem;
background: var(--color-surface, #fff);
}
/* Kategorie-Chip im Ticket-Kopf. */
.ticket-card__category {
font-size: 0.75rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--color-accent-bg, #eef2ff);
color: var(--color-accent-text, #3730a3);
}
/* „Hat das geholfen?"-Leiste auf gelösten Tickets. */
.ticket-card__helpful {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0 0 0.85rem;
font-size: 0.9rem;
}
.ticket-card__helpful-q {
font-weight: 600;
}
.ticket-card__helpful-thanks {
color: var(--color-success-text, #166534);
font-weight: 600;
}
.btn--small {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
.ticket-card__trash-notice {
border-left: 3px solid var(--color-danger, #ef4444);

View File

@@ -1201,6 +1201,9 @@ export const de = {
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
/** „Ähnliche bereits gelöste Tickets" im Melde-Fenster. */
similarTitle: 'Schon mal gelöst? Vielleicht hilft eines davon:',
similarOpen: 'ansehen',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
@@ -1276,6 +1279,17 @@ export const de = {
emptyOpen: 'Keine offenen Tickets.',
emptyClosed: 'Noch keine geschlossenen Tickets.',
emptyDeleted: 'Der Papierkorb ist leer.',
/** Suche + Kategorie-Filter. */
searchPlaceholder: 'Tickets durchsuchen …',
categoryFilterLabel: 'Nach Kategorie filtern',
categoryAll: 'Alle Kategorien',
emptySearch: 'Keine Tickets passen zu Suche/Filter.',
/** Daumen-Rückmeldung auf gelösten Tickets. */
helpfulQuestion: 'Hat dir das geholfen?',
helpfulYes: '👍 Ja',
helpfulNo: '👎 Nein',
helpfulThanks: 'Danke für die Rückmeldung!',
helpfulReopenNote: 'Schade — ich öffne das Ticket wieder. Bitte schreib kurz, was noch fehlt.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */