Files
GerbilManager/gerbil-manager-web/src/pages/TicketsPage.tsx
Gulum 8aaddea4ef fix(tickets): Tab + Scrollposition überleben Browser-Zurück
Zwei Ursachen, warum es auf der Tickets-Seite nicht ging (am echten Firefox/Chromium
gegen die Live-App verifiziert):

1) Tab ging verloren: der gewählte Filter-Tab (z. B. „Geschlossen") war reiner
   Component-State und sprang nach „Zurück" (Remount) auf „Offen" zurück — also war man
   in der falschen, kurzen Liste. Jetzt wird der Tab in der Session gemerkt und
   wiederhergestellt (userPicked startet weiterhin false, damit ?focus=-Deep-Links
   weiter funktionieren).

2) Scroll-Save-Heuristik zu unzuverlässig: Beim Wegnavigieren schrumpft der Inhalt von
   .app-main → der Container klemmt auf eine kleinere Position → dieses scroll-Event
   überschrieb die gemerkte Stelle. Erkennung präzisiert: nur verwerfen, wenn Position
   UND scrollHeight gleichzeitig SINKEN (= Inhalt geschrumpft/Seitenwechsel). Eine echte
   Nutzer-Scrollung verkleinert die scrollHeight nie — damit kein fälschliches Verwerfen
   echter Positionen mehr (vorherige „Höhe geändert"-Heuristik schluckte zu viel).

Tests: scroll-restore.spec erweitert (realistisches 2-Schritt-Scrollen + Tickets-Tab
überlebt Zurück), desktop+phone grün; vitest 149 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:54:55 +02:00

1057 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FEEDBACK-TICKETS: "Meine Tickets" — verwaltet die eingereichten "Fehler melden"-Berichte.
*
* Erreichbar über die Hilfe-Seite (/hilfe → „Meine Tickets") unter /hilfe/tickets.
* 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" (NeedsInfo —
* wartet auf die Züchterin), „Offen" (Open + Answered — von der KI zu erledigen, beantwortete
* Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
*/
import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import {
listFeedback,
updateFeedback,
deleteFeedback,
restoreFeedback,
uploadAttachment,
deleteAttachment,
attachmentUrl,
readFileAsUpload,
type FeedbackTicket,
} from '../api/feedback'
import { getGerbil } from '../api/gerbils'
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import PushToggle from '../components/PushToggle'
import './tickets.css'
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
type ResolvedRef = { id: string; name: string } | null
/**
* 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).
* - refNames: aufgelöste Kurz-Verweise je `typ:code` (die KI kann `tier:3c5b747b` schreiben
* → Name + Link). Wert `undefined` = noch nicht geladen, `null` = unbekannt/mehrdeutig.
*/
const TicketRefsContext = createContext<{
ticketIds: Set<string>
gerbilNames: Record<string, string | null>
refNames: Record<string, ResolvedRef>
}>({ ticketIds: new Set(), gerbilNames: {}, refNames: {} })
/** Cache-Schlüssel für einen Kurz-Verweis. */
function refKey(type: string, code: string): string {
return `${type.toLowerCase()}:${code.replace(/-/g, '').toLowerCase()}`
}
const t = de.feedback.tickets
const ctx = de.feedback.contexts
const nav = de.nav
/** Kontext-Label (Ansicht); fällt für unbekannte Werte auf den Rohwert zurück. */
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 RichBlock = { type: 'p'; text: string } | { type: 'ul'; items: string[] }
/** Block-Ebene: aufeinanderfolgende Aufzählungszeilen (-, *, •) zu einer Liste gruppieren. */
function parseBlocks(text: string): RichBlock[] {
const blocks: RichBlock[] = []
let para: string | null = null
let list: string[] | null = null
const flushPara = () => {
if (para !== null) {
blocks.push({ type: 'p', text: para })
para = null
}
}
const flushList = () => {
if (list) {
blocks.push({ type: 'ul', items: list })
list = null
}
}
for (const line of text.split('\n')) {
const bullet = line.match(/^\s*[-*•]\s+(.*)$/)
if (bullet) {
flushPara()
;(list ??= []).push(bullet[1])
} else {
flushList()
para = para === null ? line : `${para}\n${line}`
}
}
flushPara()
flushList()
return blocks
}
/** 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>
))}
</>
)
}
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Kurz-Verweise (tier:…), IDs, Umbrüche. */
function InlineMarkdown({ text }: { text: string }) {
const { ticketIds, gerbilNames, refNames } = useContext(TicketRefsContext)
// Gruppen: 1/2 = Markdown-Link, 3 = fett, 4 = code, 5/6 = Kurz-Verweis (typ/code), 7 = nackte GUID.
const re = new RegExp(
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|` +
`\\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)|(${GUID_RE})`,
'g',
)
const nodes: ReactNode[] = []
let last = 0
let m: RegExpExecArray | null
let k = 0
while ((m = re.exec(text)) !== null) {
if (m.index > last) nodes.push(<TextWithBreaks key={k++} value={text.slice(last, m.index)} />)
if (m[1] !== undefined && m[2] !== undefined) {
const href = m[2]
nodes.push(
href.startsWith('/') ? (
<Link key={k++} to={href} className="ticket-link">
{m[1]}
</Link>
) : (
<a key={k++} href={href} className="ticket-link" target="_blank" rel="noreferrer">
{m[1]}
</a>
),
)
} else if (m[3] !== undefined) {
nodes.push(<strong key={k++}>{m[3]}</strong>)
} else if (m[4] !== undefined) {
nodes.push(
<code key={k++} className="ticket-code">
{m[4]}
</code>,
)
} else if (m[5] !== undefined && m[6] !== undefined) {
// Kurz-Verweis tier:/kontakt:/wurf:/gehege: → aufgelöster Name + Link, sonst Roh-Text.
const type = m[5] as RefType
const resolved = refNames[refKey(type, m[6])]
if (resolved) {
nodes.push(
<Link key={k++} to={`${REF_ROUTE[type]}/${resolved.id}`} className="ticket-link">
{resolved.name}
</Link>,
)
} else {
nodes.push(<TextWithBreaks key={k++} value={m[0]} />)
}
} else {
const guid = m[7]
const gn = gerbilNames[guid]
if (ticketIds.has(guid)) {
nodes.push(
<Link key={k++} to={`/hilfe/tickets?focus=${guid}`} className="ticket-link">
{t.ticketRefLabel}
</Link>,
)
} else if (gn) {
nodes.push(
<Link key={k++} to={`/rennmaeuse/${guid}`} className="ticket-link">
{gn}
</Link>,
)
} else {
nodes.push(<TextWithBreaks key={k++} value={guid} />)
}
}
last = re.lastIndex
}
if (last < text.length) nodes.push(<TextWithBreaks key={k} value={text.slice(last)} />)
return <>{nodes}</>
}
/** Rendert Ticket-Text als Markdown-Teilmenge (Absätze, Aufzählungen, Inline-Formatierung). */
function RichText({ text }: { text: string }) {
const blocks = useMemo(() => parseBlocks(text), [text])
return (
<>
{blocks.map((b, i) =>
b.type === 'ul' ? (
<ul key={i} className="ticket-md-list">
{b.items.map((it, j) => (
<li key={j}>
<InlineMarkdown text={it} />
</li>
))}
</ul>
) : (
<InlineMarkdown key={i} text={b.text} />
),
)}
</>
)
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** Gefilterte Ansichten (Filter-Chips). */
type TicketView = 'dialog' | 'open' | 'closed' | 'deleted'
/** Auto-Fallback-Reihenfolge, wenn die gewünschte Ansicht leer ist — „Gelöscht" NICHT (Papierkorb). */
const FALLBACK_VIEWS: TicketView[] = ['open', 'dialog', 'closed']
/**
* Zuordnung Ticket → gefilterte Ansicht.
* - „Gelöscht" (deleted): soft-gelöscht (deletedAt gesetzt) — hat Vorrang vor dem Status.
* - „Rückfragen" (dialog): NUR NeedsInfo — wartet auf die Antwort der Züchterin.
* - „Offen" (open): Open + Answered — von der KI zu erledigen. Sobald die Züchterin
* geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen"
* (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben.
* - „Geschlossen" (closed): Resolved.
*/
function viewOf(ticket: Pick<FeedbackTicket, 'status' | 'deletedAt'>): TicketView {
if (ticket.deletedAt) return 'deleted'
if (ticket.status === 'Resolved') return 'closed'
if (ticket.status === 'NeedsInfo') 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.
// Der zuletzt gewählte Tab wird in der Session gemerkt, damit man nach „Zurück" (Remount)
// NICHT wieder im falschen Tab („Offen") landet. userPicked startet bewusst false, damit ein
// frischer ?focus=-Deep-Link weiterhin in den passenden Tab springen darf.
const [view, setView] = useState<TicketView>(() => {
try {
const v = sessionStorage.getItem('tickets-view')
if (v === 'dialog' || v === 'open' || v === 'closed' || v === 'deleted') return v
} catch {
/* ignorieren */
}
return 'open'
})
const [userPicked, setUserPicked] = useState(false)
async function handleToggleStatus(ticket: FeedbackTicket) {
try {
if (ticket.status === 'Resolved') {
// Wiederöffnen → zurück in die „Rückfragen" (NeedsInfo). Hatte das Ticket bereits eine
// offene Rückfrage (z. B. eine manuell gelöste Rückfrage), wird genau diese Frage wieder
// geöffnet (kein neuer Hinweis, kein Zeitstempel). War es dagegen ein echt gelöstes Ticket
// ohne Rückfrage, wird um mehr Infos gebeten — und das Backend stempelt „Wieder geöffnet am".
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
await updateFeedback(ticket.id, {
status: 'NeedsInfo',
question: hadRueckfrage ? ticket.question! : t.reopenRequest,
})
tickets.reload()
toast.success(t.reopenedToast)
} else {
await updateFeedback(ticket.id, { status: 'Resolved' })
tickets.reload()
toast.success(t.resolvedToast)
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError)
}
}
async function handleSaveMessage(ticket: FeedbackTicket, message: string) {
await updateFeedback(ticket.id, { message })
tickets.reload()
toast.success(t.saved)
}
async function handleAnswer(ticket: FeedbackTicket, answer: string) {
await updateFeedback(ticket.id, { answer })
tickets.reload()
toast.success(t.answeredToast)
}
async function handleDelete(ticket: FeedbackTicket) {
if (!window.confirm(t.confirmDelete)) return
try {
await deleteFeedback(ticket.id)
tickets.reload()
toast.success(t.deletedToast)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.deleteError)
}
}
async function handleRestore(ticket: FeedbackTicket) {
try {
await restoreFeedback(ticket.id)
tickets.reload()
toast.success(t.restoredToast)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.restoreError)
}
}
// 👍/👎 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)
}
}
async function handleAddAttachment(ticket: FeedbackTicket, file: File) {
try {
await uploadAttachment(ticket.id, await readFileAsUpload(file))
tickets.reload()
toast.success(t.attachmentAdded)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
}
}
async function handleDeleteAttachment(attachmentId: string) {
try {
await deleteAttachment(attachmentId)
tickets.reload()
toast.success(t.attachmentRemoved)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
}
}
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>>({})
// Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`.
const [refNames, setRefNames] = useState<Record<string, ResolvedRef>>({})
// 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, deleted: 0 }
for (const ticket of rows ?? []) c[viewOf(ticket)] += 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) : 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
: FALLBACK_VIEWS.find((v) => counts[v] > 0) ?? desiredView
const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView)
// Suche (Volltext über Nachricht/Frage/Antwort/Name/Kategorie) + Kategorie-Filter, jeweils
// INNERHALB der aktiven Ansicht.
const [query, setQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
// Vorhandene Kategorien (über alle nicht-gelöschten Tickets) für das Auswahlmenü.
const categories = useMemo(() => {
const set = new Set<string>()
for (const tk of rows ?? []) if (!tk.deletedAt && tk.category) set.add(tk.category)
return [...set].sort((a, b) => a.localeCompare(b, 'de'))
}, [rows])
const displayed = useMemo(() => {
const q = query.trim().toLowerCase()
return visible.filter((tk) => {
if (categoryFilter && tk.category !== categoryFilter) return false
if (!q) return true
const hay = [tk.message, tk.question, tk.answer, tk.fixNote, tk.entityName, tk.category]
.filter(Boolean)
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [visible, query, categoryFilter])
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
useEffect(() => {
if (!focusId) return
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, displayed])
// (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle
// Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.)
// 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])
// Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) in den sichtbaren Tickets sammeln und
// einmalig über POST /refs/resolve auflösen — so kann die KI im Text die Kurz-ID nutzen.
const candidateRefs = useMemo(() => {
const map = new Map<string, { type: RefType; code: string }>()
const add = (txt?: string | null) => {
if (!txt) return
const re = new RegExp(REF_TOKEN_RE.source, REF_TOKEN_RE.flags)
let m: RegExpExecArray | null
while ((m = re.exec(txt)) !== null) {
const type = m[1] as RefType
map.set(refKey(type, m[2]), { type, code: m[2] })
}
}
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 map
}, [visible])
useEffect(() => {
const missing = [...candidateRefs].filter(([key]) => !(key in refNames))
if (missing.length === 0) return
let cancelled = false
resolveRefs(missing.map(([, r]) => r))
.then((results) => {
if (cancelled) return
setRefNames((prev) => {
const next = { ...prev }
for (const r of results) {
next[refKey(r.type, r.code)] = r.id && r.name ? { id: r.id, name: r.name } : null
}
return next
})
})
.catch(() => {
/* Auflösung fehlgeschlagen — Tokens bleiben Roh-Text. */
})
return () => {
cancelled = true
}
}, [candidateRefs, refNames])
const emptyText: Record<TicketView, string> = {
dialog: t.emptyDialog,
open: t.emptyOpen,
closed: t.emptyClosed,
deleted: t.emptyDeleted,
}
return (
<section className="page tickets-page">
<nav className="tickets-breadcrumb" aria-label="Brotkrümel">
<Link to="/hilfe">{nav.help}</Link>
<span aria-hidden="true"> </span>
<span>{t.title}</span>
</nav>
<h2>{t.title}</h2>
<p className="tickets-subtitle">{t.subtitle}</p>
<PushToggle />
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
{rows === null ? null : rows.length === 0 && !tickets.error ? (
<p className="tickets-empty">{t.empty}</p>
) : (
<>
<div className="tickets-filters" role="group" aria-label={t.title}>
{(['dialog', 'open', 'closed', 'deleted'] 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)
try {
sessionStorage.setItem('tickets-view', v)
} catch {
/* ignorieren */
}
}}
>
{t.filters[v]}
<span className="tickets-filter__count">{counts[v]}</span>
</button>
))}
</div>
<div className="tickets-search">
<input
type="search"
className="tickets-search__input"
placeholder={t.searchPlaceholder}
aria-label={t.searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{categories.length > 0 && (
<select
className="tickets-search__category"
aria-label={t.categoryFilterLabel}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">{t.categoryAll}</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
</div>
{displayed.length === 0 ? (
<p className="tickets-empty">{query || categoryFilter ? t.emptySearch : emptyText[activeView]}</p>
) : (
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
<ul className="tickets-list">
{displayed.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
onToggleStatus={() => handleToggleStatus(ticket)}
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)}
onRestore={() => handleRestore(ticket)}
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
onAddAttachment={(file) => handleAddAttachment(ticket, file)}
onDeleteAttachment={handleDeleteAttachment}
/>
))}
</ul>
</TicketRefsContext.Provider>
)}
</>
)}
</section>
)
}
interface TicketCardProps {
ticket: FeedbackTicket
onToggleStatus: () => void
onSaveMessage: (message: string) => Promise<void>
onAnswer: (answer: string) => Promise<void>
onDelete: () => void
onRestore: () => void
onHelpful: (helpful: boolean) => void
onAddAttachment: (file: File) => void
onDeleteAttachment: (attachmentId: string) => void
}
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
function statusBadge(status: FeedbackTicket['status']): { label: string; modifier: string } {
switch (status) {
case 'Resolved':
return { label: `${t.statusResolved}`, modifier: 'ticket-badge--resolved' }
case 'NeedsInfo':
return { label: t.statusNeedsInfo, modifier: 'ticket-badge--needsinfo' }
case 'Answered':
return { label: t.statusAnswered, modifier: 'ticket-badge--answered' }
default:
return { label: t.statusOpen, modifier: 'ticket-badge--open' }
}
}
/** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
const TRASH_RETENTION_DAYS = 30
function TicketCard({
ticket,
onToggleStatus,
onSaveMessage,
onAnswer,
onDelete,
onRestore,
onHelpful,
onAddAttachment,
onDeleteAttachment,
}: TicketCardProps) {
const toast = useToast()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(ticket.message)
const [saving, setSaving] = useState(false)
// 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 deleted = !!ticket.deletedAt
const badge = statusBadge(ticket.status)
// Verbleibende Tage bis zur endgültigen Löschung (Soft-Delete-Countdown). `new Date()` (wie
// anderswo im Code) statt Date.now() — letzteres verstößt gegen die Purity-Lintregel.
const trashDaysLeft = ticket.deletedAt
? Math.max(
0,
Math.ceil(
(new Date(ticket.deletedAt).getTime() +
TRASH_RETENTION_DAYS * 86_400_000 -
new Date().getTime()) /
86_400_000,
),
)
: null
const [answerEditing, setAnswerEditing] = useState(false)
// Erstantwort: Rückfrage offen, noch keine Antwort.
const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved))
// Nachträglich ändern: bereits beantwortet (Answered) — die Züchterin darf ihre letzte
// Info noch korrigieren, BEVOR die KI das Ticket bearbeitet.
const canAmend = !!ticket.answer && ticket.status === 'Answered'
// Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf.
// Im Papierkorb (deleted) ist nichts interaktiv außer „Wiederherstellen".
const showAnswerForm = !deleted && (canAnswerNew || answerEditing || (canAmend && !!answerDraft))
// Der ursprüngliche Ticket-Text ist nur bearbeitbar, SOLANGE es noch keine Antwort gibt
// UND keine Rückfrage offen ist (NeedsInfo). Sobald die Züchterin geantwortet hat (oder die
// KI nachgefragt hat), bleibt der Erst-Text fix — editierbar ist dann nur noch die neueste
// Antwort (und auch die nur, bis die KI darauf reagiert hat, s. canAmend).
const canEditMessage = !deleted && !ticket.answer && ticket.status !== 'NeedsInfo'
function startAmend() {
if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
setAnswerEditing(true)
}
function cancelAmend() {
setAnswerEditing(false)
setAnswerDraft('')
}
function startEdit() {
setDraft(ticket.message)
setEditing(true)
}
async function save() {
const trimmed = draft.trim()
if (!trimmed) {
toast.error(t.emptyMessage)
return
}
setSaving(true)
try {
await onSaveMessage(trimmed)
setEditing(false)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError)
} finally {
setSaving(false)
}
}
async function submitAnswer() {
const trimmed = answerDraft.trim()
if (!trimmed) {
toast.error(t.emptyAnswer)
return
}
setAnswering(true)
try {
await onAnswer(trimmed)
setAnswerDraft('')
setAnswerEditing(false)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError)
} finally {
setAnswering(false)
}
}
return (
<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.category && <span className="ticket-card__category">{ticket.category}</span>}
{ticket.entityName &&
(entityLink ? (
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
{ticket.entityName}
</Link>
) : (
<span className="ticket-card__entity">{ticket.entityName}</span>
))}
</div>
{editing ? (
<div className="ticket-card__edit">
<label className="ticket-card__edit-label" htmlFor={`ticket-edit-${ticket.id}`}>
{t.editLabel}
</label>
<textarea
id={`ticket-edit-${ticket.id}`}
className="ticket-card__textarea"
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={4}
autoFocus
/>
<div className="ticket-card__actions">
<button type="button" className="btn" onClick={() => setEditing(false)} disabled={saving}>
{t.cancel}
</button>
<button type="button" className="btn btn--primary" onClick={save} disabled={saving}>
{saving ? t.saving : t.save}
</button>
</div>
</div>
) : (
<>
<div className="ticket-card__message">
<RichText text={ticket.message} />
</div>
{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>
<div className="ticket-card__thread-text">
<RichText text={entry.text} />
</div>
{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>
<div className="ticket-card__question-text">
<RichText text={ticket.question} />
</div>
</div>
)}
{showAnswerForm ? (
<div className="ticket-card__answer-form">
<label className="ticket-card__edit-label" htmlFor={`ticket-answer-${ticket.id}`}>
{t.answerLabel}
</label>
<textarea
id={`ticket-answer-${ticket.id}`}
className="ticket-card__textarea"
value={answerDraft}
onChange={(e) => setAnswerDraft(e.target.value)}
placeholder={t.answerPlaceholder}
rows={3}
/>
<div className="ticket-card__actions">
{answerEditing && (
<button type="button" className="btn" onClick={cancelAmend} disabled={answering}>
{t.cancel}
</button>
)}
<button
type="button"
className="btn btn--primary"
onClick={submitAnswer}
disabled={answering}
>
{answering ? t.answering : t.answerButton}
</button>
</div>
</div>
) : ticket.answer ? (
<div className="ticket-card__answer">
<span className="ticket-card__answer-label">{t.answerLabel}</span>
<div className="ticket-card__answer-text">
<RichText text={ticket.answer} />
</div>
{ticket.answeredAt && (
<span className="ticket-card__answer-date">
{t.answeredOn}: {formatDate(ticket.answeredAt)}
</span>
)}
{canAmend && (
<div className="ticket-card__actions">
<button type="button" className="btn" onClick={startAmend}>
{t.amendAnswer}
</button>
</div>
)}
</div>
) : null}
{resolved && ticket.fixNote && (
<div className="ticket-card__changelog">
<span className="ticket-card__changelog-label">{t.changelogLabel}</span>
<div className="ticket-card__changelog-text">
<RichText text={ticket.fixNote} />
</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>
)}
{(ticket.attachments.length > 0 || !deleted) && (
<div className="ticket-card__attachments">
<span className="ticket-card__attachments-label">{t.attachmentsLabel}</span>
<div className="ticket-card__attachments-grid">
{ticket.attachments.map((att) =>
att.contentType.startsWith('image/') ? (
<a
key={att.id}
href={attachmentUrl(att.id)}
target="_blank"
rel="noopener noreferrer"
className="ticket-card__attachment"
>
<img src={attachmentUrl(att.id)} alt={att.fileName} loading="lazy" />
{!deleted && (
<button
type="button"
className="ticket-card__attachment-remove"
title={t.removeAttachment}
aria-label={t.removeAttachment}
onClick={(e) => {
e.preventDefault()
onDeleteAttachment(att.id)
}}
>
</button>
)}
</a>
) : (
<span key={att.id} className="ticket-card__attachment ticket-card__attachment--file">
<a href={attachmentUrl(att.id)} target="_blank" rel="noopener noreferrer">
{att.fileName}
</a>
{!deleted && (
<button
type="button"
className="ticket-card__attachment-remove"
title={t.removeAttachment}
aria-label={t.removeAttachment}
onClick={() => onDeleteAttachment(att.id)}
>
</button>
)}
</span>
),
)}
{!deleted && (
<label className="ticket-card__attachment-add">
+ {t.addAttachment}
<input
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onAddAttachment(f)
e.currentTarget.value = ''
}}
/>
</label>
)}
</div>
</div>
)}
{deleted && trashDaysLeft !== null && (
<p className="ticket-card__trash-notice">
{trashDaysLeft === 0
? t.trashCountdownToday
: trashDaysLeft === 1
? t.trashCountdownTomorrow
: t.trashCountdownDays.replace('{n}', String(trashDaysLeft))}
</p>
)}
<dl className="ticket-card__meta">
<div>
<dt>{t.createdLabel}</dt>
<dd>{formatDate(ticket.createdAt)}</dd>
</div>
{resolved && (
<div>
<dt>{t.resolvedLabel}</dt>
<dd>{formatDate(ticket.resolvedAt)}</dd>
</div>
)}
{ticket.reopenedAt && (
<div>
<dt>{t.reopenedLabel}</dt>
<dd>{formatDate(ticket.reopenedAt)}</dd>
</div>
)}
{deleted && (
<div>
<dt>{t.deletedLabel}</dt>
<dd>{formatDate(ticket.deletedAt)}</dd>
</div>
)}
</dl>
<div className="ticket-card__actions">
{deleted ? (
<button type="button" className="btn btn--primary" onClick={onRestore}>
{t.restore}
</button>
) : (
<>
<button type="button" className="btn" onClick={onToggleStatus}>
{resolved ? t.reopen : t.markResolved}
</button>
{canEditMessage && (
<button type="button" className="btn" onClick={startEdit}>
{t.edit}
</button>
)}
<button type="button" className="btn btn--danger" onClick={onDelete}>
{t.delete}
</button>
</>
)}
</div>
</>
)}
</li>
)
}