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>
1057 lines
38 KiB
TypeScript
1057 lines
38 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|