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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user