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> /// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
DateTimeOffset? ReopenedAt = null, DateTimeOffset? ReopenedAt = null,
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary> /// <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> /// <summary>
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status, /// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
@@ -60,5 +64,9 @@ namespace GerbilManagerWebAPI.Dtos
string? Question, string? Question,
string? Answer, string? Answer,
string? FixNote, 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; 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(); await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity)); 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, 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.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext, 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") b.Property<DateTimeOffset?>("AnsweredAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTimeOffset?>("ClientTimestamp") b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -918,6 +921,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("GerbilId") b.Property<Guid?>("GerbilId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<bool?>("Helpful")
.HasColumnType("boolean");
b.Property<Guid?>("LitterId") b.Property<Guid?>("LitterId")
.HasColumnType("uuid"); .HasColumnType("uuid");

View File

@@ -70,6 +70,18 @@ namespace GerbilManagerWebAPI.Models
/// </summary> /// </summary>
public DateTimeOffset? DeletedAt { get; set; } 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> /// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
public string? Question { get; set; } public string? Question { get; set; }

View File

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

View File

@@ -442,6 +442,7 @@ export function seedDb(): MockDb {
userAgent: null, userAgent: null,
createdAt: '2026-06-10T09:00:00Z', createdAt: '2026-06-10T09:00:00Z',
status: 'Resolved', status: 'Resolved',
category: 'Stammbaum',
resolvedAt: '2026-06-12T14:00:00Z', resolvedAt: '2026-06-12T14:00:00Z',
question: null, question: null,
answer: 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.reopenRequest)).toBeVisible()
await expect(reopened.getByText(tt.reopenedLabel)).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. * Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
*/ */
deletedAt: string | null 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). */ /** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
question: string | null question: string | null
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */ /** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
@@ -95,6 +99,10 @@ export interface FeedbackUpdate {
fixNote?: string fixNote?: string
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */ /** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
agentContext?: string 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> { 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 * dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system. * 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 { de } from '../strings/de'
import { ApiError } from '../api/client' 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 { useToast } from './toast'
import './reportErrorDialog.css' 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 { export interface ReportErrorContext {
context: FeedbackContext context: FeedbackContext
gerbilId?: string | null gerbilId?: string | null
@@ -60,6 +66,46 @@ function ReportErrorDialogBody({
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null) 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. // Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
useEffect(() => { useEffect(() => {
try { try {
@@ -161,6 +207,21 @@ function ReportErrorDialogBody({
rows={5} 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">
<div className="report-error__debug-title">{t.debugTitle}</div> <div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list"> <dl className="report-error__debug-list">

View File

@@ -147,3 +147,24 @@
gap: 0.5rem; gap: 0.5rem;
margin-top: 1.1rem; 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 const rows = tickets.data
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende // 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) 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. // Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
useEffect(() => { useEffect(() => {
if (!focusId) return if (!focusId) return
@@ -364,7 +410,7 @@ export default function TicketsPage() {
el.classList.add('ticket-card--focus') el.classList.add('ticket-card--focus')
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500) const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [focusId, visible]) }, [focusId, displayed])
// Scrollposition laufend in der Session merken (für die Zurück-Navigation). // Scrollposition laufend in der Session merken (für die Zurück-Navigation).
const SCROLL_KEY = 'tickets-scroll-y' const SCROLL_KEY = 'tickets-scroll-y'
@@ -523,12 +569,38 @@ export default function TicketsPage() {
))} ))}
</div> </div>
{visible.length === 0 ? ( <div className="tickets-search">
<p className="tickets-empty">{emptyText[activeView]}</p> <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 }}> <TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
<ul className="tickets-list"> <ul className="tickets-list">
{visible.map((ticket) => ( {displayed.map((ticket) => (
<TicketCard <TicketCard
key={ticket.id} key={ticket.id}
ticket={ticket} ticket={ticket}
@@ -537,6 +609,7 @@ export default function TicketsPage() {
onAnswer={(answer) => handleAnswer(ticket, answer)} onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)} onDelete={() => handleDelete(ticket)}
onRestore={() => handleRestore(ticket)} onRestore={() => handleRestore(ticket)}
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
/> />
))} ))}
</ul> </ul>
@@ -555,6 +628,7 @@ interface TicketCardProps {
onAnswer: (answer: string) => Promise<void> onAnswer: (answer: string) => Promise<void>
onDelete: () => void onDelete: () => void
onRestore: () => void onRestore: () => void
onHelpful: (helpful: boolean) => void
} }
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */ /** 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). */ /** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
const TRASH_RETENTION_DAYS = 30 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 toast = useToast()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(ticket.message) const [draft, setDraft] = useState(ticket.message)
@@ -684,6 +766,7 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete,
<div className="ticket-card__top"> <div className="ticket-card__top">
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span> <span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
<span className="ticket-card__context">{contextLabel(ticket.context)}</span> <span className="ticket-card__context">{contextLabel(ticket.context)}</span>
{ticket.category && <span className="ticket-card__category">{ticket.category}</span>}
{ticket.entityName && {ticket.entityName &&
(entityLink ? ( (entityLink ? (
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}> <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> </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 && ( {deleted && trashDaysLeft !== null && (
<p className="ticket-card__trash-notice"> <p className="ticket-card__trash-notice">
{trashDaysLeft === 0 {trashDaysLeft === 0

View File

@@ -311,6 +311,60 @@
word-break: break-word; 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. */ /* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
.ticket-card__trash-notice { .ticket-card__trash-notice {
border-left: 3px solid var(--color-danger, #ef4444); border-left: 3px solid var(--color-danger, #ef4444);

View File

@@ -1201,6 +1201,9 @@ export const de = {
success: 'Danke! Dein Fehlerbericht wurde gesendet.', success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.', error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.', 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". */ /** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert', idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.', idCopyFailed: 'ID konnte nicht kopiert werden.',
@@ -1276,6 +1279,17 @@ export const de = {
emptyOpen: 'Keine offenen Tickets.', emptyOpen: 'Keine offenen Tickets.',
emptyClosed: 'Noch keine geschlossenen Tickets.', emptyClosed: 'Noch keine geschlossenen Tickets.',
emptyDeleted: 'Der Papierkorb ist leer.', 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. */ /** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert', changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */ /** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */