/** * 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 gerbilNames: Record refNames: Record }>({ 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)[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) => ( {i > 0 &&
} {line}
))} ) } /** 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() if (m[1] !== undefined && m[2] !== undefined) { const href = m[2] nodes.push( href.startsWith('/') ? ( {m[1]} ) : ( {m[1]} ), ) } else if (m[3] !== undefined) { nodes.push({m[3]}) } else if (m[4] !== undefined) { nodes.push( {m[4]} , ) } 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( {resolved.name} , ) } else { nodes.push() } } else { const guid = m[7] const gn = gerbilNames[guid] if (ticketIds.has(guid)) { nodes.push( {t.ticketRefLabel} , ) } else if (gn) { nodes.push( {gn} , ) } else { nodes.push() } } last = re.lastIndex } if (last < text.length) nodes.push() 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' ? (
    {b.items.map((it, j) => (
  • ))}
) : ( ), )} ) } 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): 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(() => { 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= 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>({}) // Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`. const [refNames, setRefNames] = useState>({}) // Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips). const counts = useMemo(() => { const c: Record = { dialog: 0, open: 0, closed: 0, deleted: 0 } for (const ticket of rows ?? []) c[viewOf(ticket)] += 1 return c }, [rows]) // Ansicht des per ?focus= angesprungenen Tickets (ohne setState im Effekt abgeleitet). const focusView = useMemo(() => { 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('') // Vorhandene Kategorien (über alle nicht-gelöschten Tickets) für das Auswahlmenü. const categories = useMemo(() => { const set = new Set() 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() 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() 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 = { dialog: t.emptyDialog, open: t.emptyOpen, closed: t.emptyClosed, deleted: t.emptyDeleted, } return (

{t.title}

{t.subtitle}

{tickets.error &&

{t.loadError}

} {rows === null ? null : rows.length === 0 && !tickets.error ? (

{t.empty}

) : ( <>
{(['dialog', 'open', 'closed', 'deleted'] as TicketView[]).map((v) => ( ))}
setQuery(e.target.value)} /> {categories.length > 0 && ( )}
{displayed.length === 0 ? (

{query || categoryFilter ? t.emptySearch : emptyText[activeView]}

) : (
    {displayed.map((ticket) => ( 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} /> ))}
)} )}
) } interface TicketCardProps { ticket: FeedbackTicket onToggleStatus: () => void onSaveMessage: (message: string) => Promise onAnswer: (answer: string) => Promise 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 (
  • {badge.label} {contextLabel(ticket.context)} {ticket.category && {ticket.category}} {ticket.entityName && (entityLink ? ( {ticket.entityName} ) : ( {ticket.entityName} ))}
    {editing ? (