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

@@ -47,7 +47,11 @@ namespace GerbilManagerWebAPI.Dtos
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
DateTimeOffset? ReopenedAt = null,
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
DateTimeOffset? DeletedAt = null);
DateTimeOffset? DeletedAt = null,
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
string? Category = null,
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
bool? Helpful = null);
/// <summary>
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
@@ -60,5 +64,9 @@ namespace GerbilManagerWebAPI.Dtos
string? Question,
string? Answer,
string? FixNote,
string? AgentContext);
string? AgentContext,
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
string? Category = null,
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
bool? Helpful = null);
}

View File

@@ -178,6 +178,17 @@ namespace GerbilManagerWebAPI.Endpoints
entity.AgentContext = c.Length == 0 ? null : c;
}
// KATEGORIE: frei wählbares Thema für Filter/Übersicht; leer = löschen.
if (input.Category is not null)
{
var cat = input.Category.Trim();
entity.Category = cat.Length == 0 ? null : cat;
}
// HILFREICH (👍/👎): nur setzen, wenn übermittelt (null = unverändert).
if (input.Helpful is not null)
entity.Helpful = input.Helpful;
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
@@ -264,6 +275,6 @@ namespace GerbilManagerWebAPI.Endpoints
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt);
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class FeedbackCategoryAndHelpful : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Category",
table: "Feedback",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "Helpful",
table: "Feedback",
type: "boolean",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Category",
table: "Feedback");
migrationBuilder.DropColumn(
name: "Helpful",
table: "Feedback");
}
}
}

View File

@@ -893,6 +893,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateTimeOffset?>("AnsweredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
@@ -918,6 +921,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<bool?>("Helpful")
.HasColumnType("boolean");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");

View File

@@ -70,6 +70,18 @@ namespace GerbilManagerWebAPI.Models
/// </summary>
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>
/// Optionale Kategorie/Thema (von der KI gesetzt) für Filter + Übersicht, z. B.
/// "Genetik", "Import", "Stammbaum", "Daten", "Foto". Frei wählbar (kein FK), null = ohne.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// War die Lösung hilfreich? (Daumen hoch/runter auf gelösten Tickets.) true = 👍,
/// false = 👎 (löst i. d. R. ein Wiederöffnen aus), null = noch keine Rückmeldung.
/// </summary>
public bool? Helpful { get; set; }
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
public string? Question { get; set; }

View File

@@ -433,6 +433,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
thread: [],
reopenedAt: null,
deletedAt: null,
category: null,
helpful: null,
}
db.feedback.push(created)
return json(route, 201, created)
@@ -468,6 +470,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
answer?: string
fixNote?: string
agentContext?: string
category?: string
helpful?: boolean
}
const row = db.feedback[idx]
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
@@ -536,6 +540,11 @@ export async function installMockApi(page: Page): Promise<MockDb> {
const c = body.agentContext.trim()
row.agentContext = c.length === 0 ? null : c
}
if (typeof body.category === 'string') {
const cat = body.category.trim()
row.category = cat.length === 0 ? null : cat
}
if (typeof body.helpful === 'boolean') row.helpful = body.helpful
return json(route, 200, row)
}
if (method === 'DELETE') {

View File

@@ -442,6 +442,7 @@ export function seedDb(): MockDb {
userAgent: null,
createdAt: '2026-06-10T09:00:00Z',
status: 'Resolved',
category: 'Stammbaum',
resolvedAt: '2026-06-12T14:00:00Z',
question: null,
answer: null,

View File

@@ -248,4 +248,67 @@ test.describe('Meine Tickets', () => {
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
})
test('Volltextsuche filtert die Ticket-Liste', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const search = page.getByPlaceholder(tt.searchPlaceholder)
await search.fill('Krümel')
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
await search.fill('xyzgibtsnicht')
await expect(page.getByText(tt.emptySearch)).toBeVisible()
})
test('Kategorie-Filter zeigt nur Tickets der gewählten Kategorie', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// Das Krümel-Ticket hat die Kategorie „Stammbaum" → als Chip sichtbar.
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.locator('.ticket-card__category')).toHaveText('Stammbaum')
// Über das Kategorie-Menü filtern.
await page.getByLabel(tt.categoryFilterLabel).selectOption('Stammbaum')
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('👍 auf gelöstem Ticket speichert Rückmeldung; 👎 öffnet es wieder', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.getByText(tt.helpfulQuestion)).toBeVisible()
// 👍 → Dank-Text, kein Wiederöffnen.
await card.getByRole('button', { name: tt.helpfulYes }).click()
await expect(card.getByText(tt.helpfulThanks)).toBeVisible()
})
test('👎 auf gelöstem Ticket öffnet es als Rückfrage wieder', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.helpfulNo }).click()
// Verlässt „Geschlossen", landet in „Rückfragen" mit Bitte um Infos.
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
})
})

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). */