feat(tickets): Rückfragen-Dialog, Verlinkungen, Entwurfssicherung + PWA-Vorbereitung

- Feedback: fixNote (Changelog), agentContext (intern), thread (Q&A-Verlauf);
  3 gefilterte Ansichten (Rückfragen/Offen/Geschlossen)
- Tickets-Text: klickbare Tier-Links (ID→Name), Ticket→Ticket-Links (?focus=),
  Markdown-Links; Antwort-Entwurf pro Ticket in localStorage
- "Fehler melden": Entwurf übersteht App-Wechsel/Schließen (ein gemeinsamer Entwurf)
- entityName als Hyperlink zum referenzierten Datensatz

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:38:23 +02:00
parent b9021fe577
commit dcf0e40f9b
18 changed files with 2592 additions and 154 deletions

View File

@@ -38,6 +38,27 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg',
expect(typeof report!.clientTimestamp).toBe('string')
})
test('Entwurf bleibt nach Schließen + Navigation + Wiederöffnen erhalten', async ({ page }) => {
skipUnlessMock()
// Auf einer Tier-Seite anfangen zu schreiben …
await page.goto('/rennmaeuse/kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await dialog.getByLabel(f.label).fill('Halb fertiger Text, will kurz was nachschauen …')
// … Fenster (versehentlich) schließen …
await dialog.getByRole('button', { name: f.close }).click()
await expect(dialog).not.toBeVisible()
// … in der App woanders hin navigieren (anderer Kontext) …
await page.goto('/wuerfe/w-kruemel')
// … „Fehler melden" erneut öffnen: der Text ist noch da.
await page.getByRole('button', { name: f.button }).click()
const dialog2 = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog2.getByLabel(f.label)).toHaveValue('Halb fertiger Text, will kurz was nachschauen …')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')

View File

@@ -412,6 +412,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
question: null,
answer: null,
answeredAt: null,
fixNote: null,
agentContext: null,
thread: [],
}
db.feedback.push(created)
return json(route, 201, created)
@@ -433,19 +436,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
status?: string
question?: string
answer?: string
fixNote?: string
agentContext?: string
}
const row = db.feedback[idx]
if (typeof body.message === 'string') {
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
row.message = body.message.trim()
}
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved).
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved). Eine vorhandene
// Frage/Antwort-Runde wird zuerst in den Verlauf (thread) verschoben.
if (typeof body.question === 'string') {
const q = body.question.trim()
row.question = q.length === 0 ? null : q
if (row.question && row.status !== 'Resolved') {
row.status = 'NeedsInfo'
row.resolvedAt = null
if (q.length > 0) {
const thread = (row.thread as Record<string, unknown>[] | undefined) ?? []
if (typeof row.question === 'string' && row.question)
thread.push({ role: 'maintainer', text: row.question, at: row.createdAt ?? null })
if (typeof row.answer === 'string' && row.answer)
thread.push({ role: 'breeder', text: row.answer, at: row.answeredAt ?? null })
row.thread = thread
row.answer = null
row.answeredAt = null
row.question = q
if (row.status !== 'Resolved') {
row.status = 'NeedsInfo'
row.resolvedAt = null
}
} else {
row.question = null
}
}
// Antwort der Züchterin → Answered + answeredAt.
@@ -471,6 +489,16 @@ export async function installMockApi(page: Page): Promise<MockDb> {
: 'Open'
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
}
// Changelog (fixNote) — laienverständlich, sichtbar.
if (typeof body.fixNote === 'string') {
const note = body.fixNote.trim()
row.fixNote = note.length === 0 ? null : note
}
// Internes Agenten-Arbeitsgedächtnis — ändert den Status nicht.
if (typeof body.agentContext === 'string') {
const c = body.agentContext.trim()
row.agentContext = c.length === 0 ? null : c
}
return json(route, 200, row)
}
if (method === 'DELETE') {

View File

@@ -411,9 +411,15 @@ export function seedDb(): MockDb {
question: null,
answer: null,
answeredAt: null,
// Changelog (laienverständlich) für die geschlossene Ansicht.
fixNote: 'Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.',
// INTERN: darf NIE in der Züchterin-UI erscheinen.
agentContext: 'GEHEIM-AGENT-CONTEXT: motherId via conflict-decisions.json gesetzt, re-ingest.',
thread: [],
},
{
// Ticket mit offener Rückfrage (NeedsInfo): die Züchterin soll hier antworten können.
// Hat bereits eine frühere, beantwortete Runde im Verlauf (thread).
id: 'feedback-needsinfo',
message: 'Der Wurf B hat ein falsches Geburtsdatum.',
context: 'litter-detail',
@@ -430,6 +436,13 @@ export function seedDb(): MockDb {
question: 'Welches Geburtsdatum ist korrekt — der 1. oder der 2. Juni?',
answer: null,
answeredAt: null,
fixNote: null,
agentContext: 'GEHEIM-AGENT-CONTEXT: warte auf DOB-Bestätigung für Wurf B.',
// Frühere, abgeschlossene Runde (älteste zuerst) — bleibt im Verlauf erhalten.
thread: [
{ role: 'maintainer', text: 'Wurde der Wurf im Mai oder Juni geboren?', at: '2026-06-13T08:00:00Z' },
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
],
},
{
id: 'feedback-open',
@@ -448,6 +461,9 @@ export function seedDb(): MockDb {
question: null,
answer: null,
answeredAt: null,
fixNote: null,
agentContext: null,
thread: [],
},
],
}

View File

@@ -1,28 +1,99 @@
/**
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
* Erreichbar über Hilfe → „Meine Tickets". Liste mit Status-Badges (Offen/Gelöst),
* gelöst markieren / wieder öffnen, Nachricht bearbeiten, löschen.
* Erreichbar über Hilfe → „Meine Tickets". Drei gefilterte Verlinkungen (Filter-Chips):
* „Rückfragen" (im Dialog), „Offen", „Geschlossen" (zeigt den Changelog/fixNote).
* Pro Ticket: gelöst markieren / wieder öffnen, Nachricht bearbeiten, antworten, löschen,
* Frage/Antwort-Verlauf (thread). Das interne `agentContext` wird NIE gerendert.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tt = de.feedback.tickets
/** In die gewünschte gefilterte Ansicht wechseln (Filter-Chip klicken). */
async function selectView(page: import('@playwright/test').Page, label: string) {
await page.locator('.tickets-filter').filter({ hasText: label }).click()
}
test.describe('Meine Tickets', () => {
test.beforeEach(() => skipUnlessMock())
test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => {
test('über Hilfe erreichbar, Default-Ansicht „Offen" zeigt offenes Ticket mit Badge', async ({
page,
}) => {
await gotoSection(page, de.nav.help)
await page.getByRole('link', { name: tt.link }).click()
await expect(page.getByRole('heading', { name: tt.title })).toBeVisible()
// Offenes Ticket (Badge „Offen") + dessen Nachricht.
// Default = „Offen": offenes Ticket + dessen Nachricht sichtbar.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
})
// Gelöstes Ticket (Badge mit Häkchen + „Gelöst").
test('drei gefilterte Verlinkungen filtern korrekt, Anzahl-Badges stimmen', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Filter-Chips vorhanden, jeweils mit Anzahl-Badge (1 pro Gruppe in den Seed-Daten).
const dialog = page.locator('.tickets-filter').filter({ hasText: tt.filters.dialog })
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
await expect(open.locator('.tickets-filter__count')).toHaveText('1')
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
// „Offen": nur das offene Ticket.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toHaveCount(0)
// „Rückfragen": nur das Dialog-Ticket (NeedsInfo).
await selectView(page, tt.filters.dialog)
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toBeVisible()
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toHaveCount(0)
// „Geschlossen": nur das gelöste Ticket.
await selectView(page, tt.filters.closed)
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible()
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
})
test('geschlossenes Ticket zeigt den Changelog (fixNote) + Datum', 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.changelogLabel)).toBeVisible()
await expect(
card.getByText('Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.'),
).toBeVisible()
// Datum „Gelöst am" in der Meta-Leiste.
await expect(card.getByText(tt.resolvedLabel)).toBeVisible()
})
test('agentContext wird NIE in der Züchterin-UI gerendert (in allen Ansichten)', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
// In jeder Ansicht prüfen: der interne Kontext-Text taucht nirgends auf.
for (const view of [tt.filters.open, tt.filters.dialog, tt.filters.closed]) {
await selectView(page, view)
await expect(page.getByText('GEHEIM-AGENT-CONTEXT', { exact: false })).toHaveCount(0)
}
})
test('Verlauf (thread) früherer Runden wird angezeigt', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card.getByText(tt.threadLabel)).toBeVisible()
// Frühere Runde (Frage + Antwort) sichtbar.
await expect(card.getByText('Wurde der Wurf im Mai oder Juni geboren?')).toBeVisible()
await expect(card.getByText('Im Juni.')).toBeVisible()
// Aktuelle offene Rückfrage ebenfalls sichtbar.
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible()
})
test('Ticket als gelöst markieren', async ({ page }) => {
@@ -35,17 +106,20 @@ test.describe('Meine Tickets', () => {
await openCard.getByRole('button', { name: tt.markResolved }).click()
// Badge wechselt auf „Gelöst", Aktion bietet jetzt „Wieder öffnen".
// Auf das Badge (.ticket-badge) zielen — „Gelöst" steht sonst auch im Meta-Label „Gelöst am".
await expect(openCard.locator('.ticket-badge--resolved')).toBeVisible()
await expect(openCard.getByRole('button', { name: tt.reopen })).toBeVisible()
// Das Ticket verlässt die „Offen"-Ansicht — die Ansicht weicht automatisch auf
// eine nicht-leere Gruppe aus. Das gelöste Ticket findet sich unter „Geschlossen".
await selectView(page, tt.filters.closed)
const resolvedCard = page
.locator('.ticket-card')
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
await expect(resolvedCard.locator('.ticket-badge--resolved')).toBeVisible()
await expect(resolvedCard.getByRole('button', { name: tt.reopen })).toBeVisible()
})
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Karte über den Datensatz-Namen (bleibt auch im Bearbeiten-Modus sichtbar)
// adressieren — die Nachricht selbst wird beim Bearbeiten durch das Textfeld ersetzt.
// „Offen"-Ansicht (Default): das offene Ticket über den Datensatz-Namen adressieren.
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await card.getByRole('button', { name: tt.edit }).click()
@@ -61,6 +135,7 @@ test.describe('Meine Tickets', () => {
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.dialog)
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
await expect(card).toBeVisible()
@@ -70,10 +145,10 @@ test.describe('Meine Tickets', () => {
await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible()
// Antworten.
await card.locator('textarea').fill('Korrekt ist der 2. Juni.')
await card.locator('.ticket-card__answer-form textarea').fill('Korrekt ist der 2. Juni.')
await card.getByRole('button', { name: tt.answerButton }).click()
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet".
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet" (bleibt in der Dialog-Ansicht).
await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible()
await expect(card.locator('.ticket-badge--answered')).toBeVisible()
await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible()
@@ -81,6 +156,7 @@ test.describe('Meine Tickets', () => {
test('Ticket löschen', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')

View File

@@ -27,6 +27,15 @@ export interface FeedbackInput {
*/
export type FeedbackStatus = 'Open' | 'NeedsInfo' | 'Answered' | 'Resolved'
/** Eine vergangene Frage/Antwort-Runde im Verlauf (Thread) eines Tickets. */
export interface FeedbackThreadEntry {
/** „maintainer" = Rückfrage, „breeder" = Antwort der Züchterin. */
role: 'maintainer' | 'breeder'
text: string
/** Zeitpunkt der Runde (ISO-8601) oder null. */
at: string | null
}
export interface Feedback {
id: string
message: string
@@ -47,6 +56,15 @@ export interface Feedback {
answer: string | null
/** Zeitpunkt der Antwort der Züchterin. */
answeredAt: string | null
/** Laienverständlicher Changelog, der beim Schließen geschrieben wird (sichtbar). */
fixNote: string | null
/**
* INTERN: Arbeitsgedächtnis des Agenten — wird vom Backend geliefert, darf aber
* NIEMALS in der Züchterin-UI angezeigt werden.
*/
agentContext: string | null
/** Frühere Frage/Antwort-Runden (älteste zuerst); die aktuelle offene Runde bleibt in question/answer. */
thread: FeedbackThreadEntry[]
}
/** Alias used by the "Meine Tickets" area for readability. */
@@ -63,6 +81,10 @@ export interface FeedbackUpdate {
question?: string
/** Antwort der Züchterin (nicht leer ⇒ Status Answered + answeredAt). */
answer?: string
/** Laienverständlicher Changelog (typisch zusammen mit status: "Resolved" gesetzt). */
fixNote?: string
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
agentContext?: string
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {

View File

@@ -44,10 +44,32 @@ function ReportErrorDialogBody({
}) {
const t = de.feedback
const toast = useToast()
const [message, setMessage] = useState('')
// EIN gemeinsamer Entwurf fürs Melde-Fenster (nicht pro Kontext): so kommt der Text auch
// dann zurück, wenn das Fenster (versehentlich) geschlossen wird, man in der App woanders
// hin navigiert (anderer Kontext) und „Fehler melden" erneut öffnet — oder die Seite am
// Handy beim App-Wechsel aus dem Speicher geworfen wird. Wird bei jeder Eingabe gesichert
// und erst nach erfolgreichem Senden gelöscht.
const draftKey = 'report-error-draft'
const [message, setMessage] = useState(() => {
try {
return localStorage.getItem(draftKey) ?? ''
} catch {
return ''
}
})
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
useEffect(() => {
try {
if (message) localStorage.setItem(draftKey, message)
else localStorage.removeItem(draftKey)
} catch {
/* localStorage nicht verfügbar — Entwurf dann nur im Speicher */
}
}, [message, draftKey])
// Focus the textarea once on mount.
useEffect(() => {
const id = window.setTimeout(() => textareaRef.current?.focus(), 0)
@@ -84,6 +106,11 @@ function ReportErrorDialogBody({
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
try {
localStorage.removeItem(draftKey)
} catch {
/* ignorieren */
}
toast.success(t.success)
onClose()
} catch (err) {

View File

@@ -5,9 +5,13 @@
* Listet alle Tickets (neueste zuerst, GET /feedback) mit Status-Badge (Offen/Gelöst).
* Pro Ticket: als gelöst markieren / wieder öffnen, Beschreibung bearbeiten (inline),
* löschen (mit Bestätigung). Erfolg/Fehler über das bestehende Toast-System.
*
* Drei gefilterte Verlinkungen (Filter-Chips, kein Pflicht-Tab): „Rückfragen" (im Dialog
* = NeedsInfo/Answered), „Offen" (Open), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { createContext, Fragment, useContext, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import {
@@ -16,10 +20,23 @@ import {
deleteFeedback,
type FeedbackTicket,
} from '../api/feedback'
import { getGerbil } from '../api/gerbils'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import './tickets.css'
/**
* Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene
* durchgereicht werden muss:
* - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket).
* - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte).
* Wert `undefined` = noch nicht geladen, `null` = nicht gefunden.
*/
const TicketRefsContext = createContext<{
ticketIds: Set<string>
gerbilNames: Record<string, string | null>
}>({ ticketIds: new Set(), gerbilNames: {} })
const t = de.feedback.tickets
const ctx = de.feedback.contexts
const nav = de.nav
@@ -29,6 +46,104 @@ function contextLabel(context: string): string {
return (ctx as Record<string, string>)[context] ?? context
}
/** Interne Route zum referenzierten Datensatz (Tier/Wurf/Kontakt) eines Tickets, falls vorhanden. */
function entityHref(ticket: FeedbackTicket): string | null {
if (ticket.gerbilId) return `/rennmaeuse/${ticket.gerbilId}`
if (ticket.litterId) return `/wuerfe/${ticket.litterId}`
if (ticket.contactId) return `/kontakte/${ticket.contactId}`
return null
}
const GUID_RE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
type RichToken = { kind: 'text'; value: string } | { kind: 'link'; href: string; label: string }
/**
* Zerlegt Ticket-Text in Text- und Link-Token. Erkannt werden:
* - Markdown-Links `[Label](/route)` → interner/externer Link,
* - nackte GUIDs: gehört sie zu einem bekannten Ticket → Link auf dieses Ticket; sonst, wenn
* sie einen Tier-Namen auflöst → Name + Link zur Rennmausakte (so kann die KI einfach die
* Tier-ID in den Text schreiben). Unauflösbare GUIDs bleiben Text.
*/
function tokenizeRich(
text: string,
ticketIds: Set<string>,
gerbilNames: Record<string, string | null>,
): RichToken[] {
const tokens: RichToken[] = []
const re = new RegExp(`\\[([^\\]]+)\\]\\(([^)]+)\\)|(${GUID_RE})`, 'g')
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
if (m.index > last) tokens.push({ kind: 'text', value: text.slice(last, m.index) })
if (m[1] !== undefined && m[2] !== undefined) {
tokens.push({ kind: 'link', href: m[2], label: m[1] })
} else {
const guid = m[3]
const gerbilName = gerbilNames[guid]
if (ticketIds.has(guid)) {
tokens.push({ kind: 'link', href: `/hilfe/tickets?focus=${guid}`, label: t.ticketRefLabel })
} else if (gerbilName) {
tokens.push({ kind: 'link', href: `/rennmaeuse/${guid}`, label: gerbilName })
} else {
tokens.push({ kind: 'text', value: guid })
}
}
last = re.lastIndex
}
if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) })
return tokens
}
/** Alle nackten GUIDs in einem Text, die KEINE Markdown-Link-Ziele sind (mögliche Tier-IDs). */
function bareGuids(text: string): string[] {
const stripped = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
return stripped.match(new RegExp(GUID_RE, 'g')) ?? []
}
/** Plain-Text mit erhaltenen Zeilenumbrüchen. */
function TextWithBreaks({ value }: { value: string }) {
const lines = value.split('\n')
return (
<>
{lines.map((line, i) => (
<Fragment key={i}>
{i > 0 && <br />}
{line}
</Fragment>
))}
</>
)
}
/** Rendert Ticket-Text mit klickbaren Tier-/Ticket-Links und Zeilenumbrüchen. */
function RichText({ text }: { text: string }) {
const { ticketIds, gerbilNames } = useContext(TicketRefsContext)
const tokens = useMemo(
() => tokenizeRich(text, ticketIds, gerbilNames),
[text, ticketIds, gerbilNames],
)
return (
<>
{tokens.map((tok, i) =>
tok.kind === 'link' ? (
tok.href.startsWith('/') ? (
<Link key={i} to={tok.href} className="ticket-link">
{tok.label}
</Link>
) : (
<a key={i} href={tok.href} className="ticket-link" target="_blank" rel="noreferrer">
{tok.label}
</a>
)
) : (
<TextWithBreaks key={i} value={tok.value} />
),
)}
</>
)
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
@@ -36,9 +151,24 @@ function formatDate(iso: string | null): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** Gefilterte Ansichten (Filter-Chips). */
type TicketView = 'dialog' | 'open' | 'closed'
/** Zuordnung Ticket-Status → gefilterte Ansicht. */
function viewOf(status: FeedbackTicket['status']): TicketView {
if (status === 'Resolved') return 'closed'
if (status === 'NeedsInfo' || status === 'Answered') return 'dialog'
return 'open'
}
export default function TicketsPage() {
const toast = useToast()
const tickets = useApi(() => listFeedback(), [])
// Default-Ansicht: „Offen" (neue, noch unbearbeitete Tickets) — fällt auf die erste
// nicht-leere Gruppe zurück, falls „Offen" leer ist. Solange die Nutzerin keinen Filter
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
const [view, setView] = useState<TicketView>('open')
const [userPicked, setUserPicked] = useState(false)
async function handleToggleStatus(ticket: FeedbackTicket) {
const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved'
@@ -76,6 +206,93 @@ export default function TicketsPage() {
const rows = tickets.data
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
// Ansicht und hebt das Ticket hervor. ticketIds = alle bekannten IDs (für nackte-GUID-Links).
const [searchParams] = useSearchParams()
const focusId = searchParams.get('focus')
const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows])
// Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link).
const [gerbilNames, setGerbilNames] = useState<Record<string, string | null>>({})
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
const counts = useMemo(() => {
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0 }
for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1
return c
}, [rows])
// Ansicht des per ?focus=<id> angesprungenen Tickets (ohne setState im Effekt abgeleitet).
const focusView = useMemo<TicketView | null>(() => {
if (!focusId || !rows) return null
const target = rows.find((r) => r.id === focusId)
return target ? viewOf(target.status) : null
}, [focusId, rows])
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
// sonst der gewählte Filter; falls leer, auf die erste nicht-leere Gruppe ausweichen.
const desiredView: TicketView = focusView && !userPicked ? focusView : view
const activeView: TicketView =
counts[desiredView] > 0
? desiredView
: (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView
const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView)
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
useEffect(() => {
if (!focusId) return
const el = document.getElementById(`ticket-${focusId}`)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('ticket-card--focus')
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
return () => clearTimeout(timer)
}, [focusId, visible])
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen,
// damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).
const candidateGerbilIds = useMemo(() => {
const ids = new Set<string>()
const add = (txt?: string | null) => {
if (!txt) return
for (const g of bareGuids(txt)) if (!ticketIds.has(g)) ids.add(g)
}
for (const tk of visible) {
add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote)
for (const e of tk.thread) add(e.text)
}
return ids
}, [visible, ticketIds])
useEffect(() => {
const missing = [...candidateGerbilIds].filter((id) => !(id in gerbilNames))
if (missing.length === 0) return
let cancelled = false
Promise.all(
missing.map((id) =>
getGerbil(id)
.then((g) => [id, g.name] as const)
.catch(() => [id, null] as const),
),
).then((pairs) => {
if (cancelled) return
setGerbilNames((prev) => {
const next = { ...prev }
for (const [id, name] of pairs) next[id] = name
return next
})
})
return () => {
cancelled = true
}
}, [candidateGerbilIds, gerbilNames])
const emptyText: Record<TicketView, string> = {
dialog: t.emptyDialog,
open: t.emptyOpen,
closed: t.emptyClosed,
}
return (
<section className="page tickets-page">
<nav className="tickets-breadcrumb" aria-label="Brotkrümel">
@@ -91,18 +308,44 @@ export default function TicketsPage() {
{rows === null ? null : rows.length === 0 && !tickets.error ? (
<p className="tickets-empty">{t.empty}</p>
) : (
<ul className="tickets-list">
{rows.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
onToggleStatus={() => handleToggleStatus(ticket)}
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)}
/>
))}
</ul>
<>
<div className="tickets-filters" role="group" aria-label={t.title}>
{(['dialog', 'open', 'closed'] as TicketView[]).map((v) => (
<button
key={v}
type="button"
className={`tickets-filter${v === activeView ? ' tickets-filter--active' : ''}`}
aria-pressed={v === activeView}
onClick={() => {
setUserPicked(true)
setView(v)
}}
>
{t.filters[v]}
<span className="tickets-filter__count">{counts[v]}</span>
</button>
))}
</div>
{visible.length === 0 ? (
<p className="tickets-empty">{emptyText[activeView]}</p>
) : (
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames }}>
<ul className="tickets-list">
{visible.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
onToggleStatus={() => handleToggleStatus(ticket)}
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)}
/>
))}
</ul>
</TicketRefsContext.Provider>
)}
</>
)}
</section>
)
@@ -135,8 +378,26 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(ticket.message)
const [saving, setSaving] = useState(false)
const [answerDraft, setAnswerDraft] = useState('')
// Antwort-Entwurf bleibt beim Verlassen/Zurückkehren erhalten (z. B. nach Klick auf
// einen Tier-Link im Frage-Text) — pro Ticket in localStorage gepuffert.
const draftKey = `ticket-answer-draft-${ticket.id}`
const [answerDraft, setAnswerDraft] = useState(() => {
try {
return localStorage.getItem(draftKey) ?? ''
} catch {
return ''
}
})
useEffect(() => {
try {
if (answerDraft) localStorage.setItem(draftKey, answerDraft)
else localStorage.removeItem(draftKey)
} catch {
/* localStorage nicht verfügbar — Entwurf dann nur im Speicher */
}
}, [answerDraft, draftKey])
const [answering, setAnswering] = useState(false)
const entityLink = entityHref(ticket)
const resolved = ticket.status === 'Resolved'
const badge = statusBadge(ticket.status)
// Die Züchterin darf antworten, solange eine Rückfrage offen ist und noch keine
@@ -183,11 +444,18 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
}
return (
<li className={`ticket-card${resolved ? ' ticket-card--resolved' : ''}`}>
<li id={`ticket-${ticket.id}`} className={`ticket-card${resolved ? ' ticket-card--resolved' : ''}`}>
<div className="ticket-card__top">
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
{ticket.entityName && <span className="ticket-card__entity">{ticket.entityName}</span>}
{ticket.entityName &&
(entityLink ? (
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
{ticket.entityName}
</Link>
) : (
<span className="ticket-card__entity">{ticket.entityName}</span>
))}
</div>
{editing ? (
@@ -214,19 +482,49 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
</div>
) : (
<>
<p className="ticket-card__message">{ticket.message}</p>
<p className="ticket-card__message">
<RichText text={ticket.message} />
</p>
{ticket.thread.length > 0 && (
<div className="ticket-card__thread">
<span className="ticket-card__thread-label">{t.threadLabel}</span>
<ol className="ticket-card__thread-list">
{ticket.thread.map((entry, i) => (
<li
key={i}
className={`ticket-card__thread-entry ticket-card__thread-entry--${entry.role}`}
>
<span className="ticket-card__thread-role">
{entry.role === 'maintainer' ? t.threadMaintainer : t.threadBreeder}
</span>
<p className="ticket-card__thread-text">
<RichText text={entry.text} />
</p>
{entry.at && (
<span className="ticket-card__thread-date">{formatDate(entry.at)}</span>
)}
</li>
))}
</ol>
</div>
)}
{ticket.question && (
<div className="ticket-card__question">
<span className="ticket-card__question-label">{t.questionLabel}</span>
<p className="ticket-card__question-text">{ticket.question}</p>
<p className="ticket-card__question-text">
<RichText text={ticket.question} />
</p>
</div>
)}
{ticket.answer ? (
<div className="ticket-card__answer">
<span className="ticket-card__answer-label">{t.answerLabel}</span>
<p className="ticket-card__answer-text">{ticket.answer}</p>
<p className="ticket-card__answer-text">
<RichText text={ticket.answer} />
</p>
{ticket.answeredAt && (
<span className="ticket-card__answer-date">
{t.answeredOn}: {formatDate(ticket.answeredAt)}
@@ -259,6 +557,15 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
</div>
) : null}
{resolved && ticket.fixNote && (
<div className="ticket-card__changelog">
<span className="ticket-card__changelog-label">{t.changelogLabel}</span>
<p className="ticket-card__changelog-text">
<RichText text={ticket.fixNote} />
</p>
</div>
)}
<dl className="ticket-card__meta">
<div>
<dt>{t.createdLabel}</dt>

View File

@@ -40,6 +40,55 @@
margin-bottom: 1rem;
}
/* GEFILTERTE VERLINKUNGEN: Filter-Chips (wie die Rennmaus-Liste). */
.tickets-filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tickets-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 999px;
background: var(--color-surface, #fff);
color: var(--color-text, #333);
cursor: pointer;
}
.tickets-filter:hover {
border-color: var(--color-accent, #2563eb);
}
.tickets-filter--active {
background: var(--color-accent, #2563eb);
border-color: var(--color-accent, #2563eb);
color: #fff;
}
.tickets-filter__count {
font-size: 0.75rem;
font-weight: 700;
min-width: 1.25rem;
text-align: center;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--color-surface-2, #eef2f7);
color: var(--color-muted, #555);
}
.tickets-filter--active .tickets-filter__count {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
.tickets-list {
list-style: none;
margin: 0;
@@ -173,6 +222,95 @@
margin: 0 0 0.75rem;
}
/* VERLAUF (Thread): frühere Frage/Antwort-Runden, dezent. */
.ticket-card__thread {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--color-border, #d4d4d4);
background: var(--color-surface-2, #f7f8fa);
border-radius: 0 8px 8px 0;
}
.ticket-card__thread-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-muted, #777);
margin-bottom: 0.4rem;
}
.ticket-card__thread-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ticket-card__thread-entry {
padding-left: 0.6rem;
border-left: 2px solid var(--color-border, #ccc);
}
.ticket-card__thread-entry--maintainer {
border-left-color: var(--color-warning, #f59e0b);
}
.ticket-card__thread-entry--breeder {
border-left-color: var(--color-accent, #2563eb);
}
.ticket-card__thread-role {
display: block;
font-size: 0.72rem;
font-weight: 700;
color: var(--color-muted, #777);
}
.ticket-card__thread-text {
margin: 0.1rem 0 0;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.92rem;
}
.ticket-card__thread-date {
display: block;
margin-top: 0.15rem;
font-size: 0.74rem;
color: var(--color-muted, #999);
}
/* CHANGELOG (fixNote): laienverständlich, grüner Akzent auf geschlossenen Tickets. */
.ticket-card__changelog {
border-left: 3px solid var(--color-success, #22c55e);
background: var(--color-success-bg, #f0fdf4);
padding: 0.55rem 0.75rem;
border-radius: 0 8px 8px 0;
margin: 0 0 0.85rem;
}
.ticket-card__changelog-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-success-text, #166534);
margin-bottom: 0.25rem;
}
.ticket-card__changelog-text {
margin: 0;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.ticket-card__meta {
display: flex;
flex-wrap: wrap;
@@ -227,3 +365,40 @@
outline: 2px solid var(--color-accent, #2563eb);
outline-offset: 1px;
}
/* Klickbare Tier-/Ticket-Links im Ticket-Text. */
.ticket-link {
color: var(--color-accent, #2563eb);
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-word;
}
.ticket-link:hover {
text-decoration: none;
}
/* Verlinkter Datensatz-Name (Tier/Wurf/Kontakt) in der Kopfzeile. */
.ticket-card__entity--link {
color: var(--color-accent, #2563eb);
text-decoration: underline;
text-underline-offset: 2px;
}
.ticket-card__entity--link:hover {
text-decoration: none;
}
/* Kurzes Hervorheben des per ?focus= angesprungenen Tickets. */
.ticket-card--focus {
animation: ticket-focus-pulse 2.5s ease-out;
}
@keyframes ticket-focus-pulse {
0%, 30% {
box-shadow: 0 0 0 3px var(--color-accent, #2563eb);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}

View File

@@ -875,6 +875,7 @@ export const de = {
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
'contact-detail': 'Kontakt',
'rpro3-import': 'RennmausPro-Import',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
@@ -929,6 +930,30 @@ export const de = {
answeredOn: 'Beantwortet am',
answeredToast: 'Antwort gesendet.',
emptyAnswer: 'Bitte gib eine Antwort ein.',
/** Gefilterte Verlinkungen (wie die Filter-Chips der Rennmaus-Liste). */
filters: {
/** Gruppe „Rückfragen": Tickets im Dialog (NeedsInfo + Answered). */
dialog: 'Rückfragen',
/** Gruppe „Offen": noch nicht bearbeitete Tickets. */
open: 'Offen',
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
closed: 'Geschlossen',
},
/** Leertext je gefilterter Ansicht. */
emptyDialog: 'Aktuell keine Tickets im Dialog.',
emptyOpen: 'Keine offenen Tickets.',
emptyClosed: 'Noch keine geschlossenen Tickets.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
threadLabel: 'Bisheriger Verlauf',
/** Rollen-Beschriftungen im Verlauf. */
threadMaintainer: 'Rückfrage',
threadBreeder: 'Deine Antwort',
/** Tooltip am verlinkten Datensatz-Namen (Tier/Wurf/Kontakt). */
entityLinkTitle: 'Datensatz ansehen',
/** Link-Beschriftung für eine im Text referenzierte andere Ticket-Nummer. */
ticketRefLabel: 'verknüpftes Ticket ansehen',
},
},
/** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */