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