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