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