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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user