|
|
|
|
@@ -11,7 +11,7 @@
|
|
|
|
|
* 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 { createContext, Fragment, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
|
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
|
|
|
import { de } from '../strings/de'
|
|
|
|
|
import { ApiError } from '../api/client'
|
|
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
|
|
listFeedback,
|
|
|
|
|
updateFeedback,
|
|
|
|
|
deleteFeedback,
|
|
|
|
|
restoreFeedback,
|
|
|
|
|
type FeedbackTicket,
|
|
|
|
|
} from '../api/feedback'
|
|
|
|
|
import { getGerbil } from '../api/gerbils'
|
|
|
|
|
@@ -227,19 +228,24 @@ function formatDate(iso: string | null): string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Gefilterte Ansichten (Filter-Chips). */
|
|
|
|
|
type TicketView = 'dialog' | 'open' | 'closed'
|
|
|
|
|
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-Status → gefilterte Ansicht.
|
|
|
|
|
* 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(status: FeedbackTicket['status']): TicketView {
|
|
|
|
|
if (status === 'Resolved') return 'closed'
|
|
|
|
|
if (status === 'NeedsInfo') return 'dialog'
|
|
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -251,13 +257,30 @@ export default function TicketsPage() {
|
|
|
|
|
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
|
|
|
|
|
const [view, setView] = useState<TicketView>('open')
|
|
|
|
|
const [userPicked, setUserPicked] = useState(false)
|
|
|
|
|
// Scrollposition über das Verlassen/Zurückkehren (z. B. Klick auf einen Tier-Link und dann
|
|
|
|
|
// Browser-Zurück) hinweg erhalten. Erst NACH dem Wiederherstellen wieder mitschreiben, damit
|
|
|
|
|
// der Sprung an den Seitenanfang beim Re-Mount die gemerkte Position nicht überschreibt.
|
|
|
|
|
const scrollRestoredRef = useRef(false)
|
|
|
|
|
|
|
|
|
|
async function handleToggleStatus(ticket: FeedbackTicket) {
|
|
|
|
|
const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved'
|
|
|
|
|
try {
|
|
|
|
|
await updateFeedback(ticket.id, { status: next })
|
|
|
|
|
tickets.reload()
|
|
|
|
|
toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
@@ -286,6 +309,16 @@ export default function TicketsPage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = tickets.data
|
|
|
|
|
|
|
|
|
|
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
|
|
|
|
|
@@ -300,8 +333,8 @@ export default function TicketsPage() {
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
@@ -309,7 +342,7 @@ export default function TicketsPage() {
|
|
|
|
|
const focusView = useMemo<TicketView | null>(() => {
|
|
|
|
|
if (!focusId || !rows) return null
|
|
|
|
|
const target = rows.find((r) => r.id === focusId)
|
|
|
|
|
return target ? viewOf(target.status) : null
|
|
|
|
|
return target ? viewOf(target) : null
|
|
|
|
|
}, [focusId, rows])
|
|
|
|
|
|
|
|
|
|
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
|
|
|
|
|
@@ -318,9 +351,9 @@ export default function TicketsPage() {
|
|
|
|
|
const activeView: TicketView =
|
|
|
|
|
counts[desiredView] > 0
|
|
|
|
|
? desiredView
|
|
|
|
|
: (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView
|
|
|
|
|
: FALLBACK_VIEWS.find((v) => counts[v] > 0) ?? desiredView
|
|
|
|
|
|
|
|
|
|
const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView)
|
|
|
|
|
const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView)
|
|
|
|
|
|
|
|
|
|
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
@@ -333,6 +366,41 @@ export default function TicketsPage() {
|
|
|
|
|
return () => clearTimeout(timer)
|
|
|
|
|
}, [focusId, visible])
|
|
|
|
|
|
|
|
|
|
// Scrollposition laufend in der Session merken (für die Zurück-Navigation).
|
|
|
|
|
const SCROLL_KEY = 'tickets-scroll-y'
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onScroll = () => {
|
|
|
|
|
if (!scrollRestoredRef.current) return
|
|
|
|
|
try {
|
|
|
|
|
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))
|
|
|
|
|
} catch {
|
|
|
|
|
/* sessionStorage nicht verfügbar — Position dann nicht gemerkt */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
|
|
|
return () => window.removeEventListener('scroll', onScroll)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Nach dem Laden der Tickets die gemerkte Scrollposition EINMALIG wiederherstellen — außer es
|
|
|
|
|
// steht ein ?focus=-Sprung an (der hat Vorrang). Erst danach beginnt das Mitschreiben (s. o.).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (scrollRestoredRef.current || rows === null) return
|
|
|
|
|
if (focusId) {
|
|
|
|
|
scrollRestoredRef.current = true
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let y = NaN
|
|
|
|
|
try {
|
|
|
|
|
y = Number(sessionStorage.getItem(SCROLL_KEY))
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignorieren */
|
|
|
|
|
}
|
|
|
|
|
scrollRestoredRef.current = true
|
|
|
|
|
if (Number.isFinite(y) && y > 0) {
|
|
|
|
|
requestAnimationFrame(() => window.scrollTo(0, y))
|
|
|
|
|
}
|
|
|
|
|
}, [rows, focusId])
|
|
|
|
|
|
|
|
|
|
// 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(() => {
|
|
|
|
|
@@ -418,6 +486,7 @@ export default function TicketsPage() {
|
|
|
|
|
dialog: t.emptyDialog,
|
|
|
|
|
open: t.emptyOpen,
|
|
|
|
|
closed: t.emptyClosed,
|
|
|
|
|
deleted: t.emptyDeleted,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
@@ -437,7 +506,7 @@ export default function TicketsPage() {
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="tickets-filters" role="group" aria-label={t.title}>
|
|
|
|
|
{(['dialog', 'open', 'closed'] as TicketView[]).map((v) => (
|
|
|
|
|
{(['dialog', 'open', 'closed', 'deleted'] as TicketView[]).map((v) => (
|
|
|
|
|
<button
|
|
|
|
|
key={v}
|
|
|
|
|
type="button"
|
|
|
|
|
@@ -467,6 +536,7 @@ export default function TicketsPage() {
|
|
|
|
|
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
|
|
|
|
|
onAnswer={(answer) => handleAnswer(ticket, answer)}
|
|
|
|
|
onDelete={() => handleDelete(ticket)}
|
|
|
|
|
onRestore={() => handleRestore(ticket)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
@@ -484,6 +554,7 @@ interface TicketCardProps {
|
|
|
|
|
onSaveMessage: (message: string) => Promise<void>
|
|
|
|
|
onAnswer: (answer: string) => Promise<void>
|
|
|
|
|
onDelete: () => void
|
|
|
|
|
onRestore: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
|
|
|
|
|
@@ -500,7 +571,10 @@ function statusBadge(status: FeedbackTicket['status']): { label: string; modifie
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete }: TicketCardProps) {
|
|
|
|
|
/** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
|
|
|
|
|
const TRASH_RETENTION_DAYS = 30
|
|
|
|
|
|
|
|
|
|
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete, onRestore }: TicketCardProps) {
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
const [editing, setEditing] = useState(false)
|
|
|
|
|
const [draft, setDraft] = useState(ticket.message)
|
|
|
|
|
@@ -526,7 +600,21 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|
|
|
|
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))
|
|
|
|
|
@@ -534,12 +622,13 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|
|
|
|
// 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.
|
|
|
|
|
const showAnswerForm = canAnswerNew || answerEditing || (canAmend && !!answerDraft)
|
|
|
|
|
// 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 = !ticket.answer && ticket.status !== 'NeedsInfo'
|
|
|
|
|
const canEditMessage = !deleted && !ticket.answer && ticket.status !== 'NeedsInfo'
|
|
|
|
|
|
|
|
|
|
function startAmend() {
|
|
|
|
|
if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
|
|
|
|
|
@@ -725,6 +814,16 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|
|
|
|
</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>
|
|
|
|
|
@@ -736,19 +835,39 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
|
|
|
|
|
<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">
|
|
|
|
|
<button type="button" className="btn" onClick={onToggleStatus}>
|
|
|
|
|
{resolved ? t.reopen : t.markResolved}
|
|
|
|
|
</button>
|
|
|
|
|
{canEditMessage && (
|
|
|
|
|
<button type="button" className="btn" onClick={startEdit}>
|
|
|
|
|
{t.edit}
|
|
|
|
|
{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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<button type="button" className="btn btn--danger" onClick={onDelete}>
|
|
|
|
|
{t.delete}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|