feat(tickets): Wiederöffnen→Rückfrage mit Zeitstempel + Soft-Delete-Papierkorb + Scroll-Erhalt
Wiederöffnen eines gelösten Tickets:
- landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen".
- War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und
„Wieder geöffnet am" (ReopenedAt) gestempelt.
- Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es
ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück.
Löschen über die UI = Soft-Delete:
- Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden.
- Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null).
- Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung
(lazy beim Abruf in GET /feedback).
Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen
Tier-Link) erhalten, statt an den Seitenanfang zu springen.
EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable).
Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets
Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user