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:
2026-06-23 11:02:04 +02:00
parent a0fa33da42
commit c0a7b1a5ee
13 changed files with 2237 additions and 43 deletions

View File

@@ -50,6 +50,16 @@ export interface Feedback {
createdAt: string
status: FeedbackStatus
resolvedAt: string | null
/**
* Zeitpunkt des Wiederöffnens — gesetzt, wenn ein ECHT gelöstes Ticket (ohne offene
* Rückfrage) wieder geöffnet wurde; null sonst. Wird als „Wieder geöffnet am" angezeigt.
*/
reopenedAt: string | null
/**
* Soft-Delete-Marker: gesetzt, wenn das Ticket über die UI gelöscht wurde (wiederherstellbar).
* Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
*/
deletedAt: string | null
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
question: string | null
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
@@ -106,7 +116,12 @@ export function answerTicket(id: string, answer: string): Promise<Feedback> {
return updateFeedback(id, { answer })
}
/** Delete a ticket. */
/** Soft-delete a ticket (moves it into the "Gelöscht" category; recoverable via restore). */
export function deleteFeedback(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}
/** Restore a soft-deleted ticket (DeletedAt → null); it returns to its prior status. */
export function restoreFeedback(id: string): Promise<Feedback> {
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
}

View File

@@ -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>
</>
)}

View File

@@ -311,6 +311,18 @@
word-break: break-word;
}
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
.ticket-card__trash-notice {
border-left: 3px solid var(--color-danger, #ef4444);
background: var(--color-danger-bg, #fef2f2);
color: var(--color-danger-text, #991b1b);
padding: 0.55rem 0.75rem;
border-radius: 0 8px 8px 0;
margin: 0 0 0.85rem;
font-size: 0.9rem;
font-weight: 600;
}
.ticket-card__meta {
display: flex;
flex-wrap: wrap;

View File

@@ -1221,20 +1221,31 @@ export const de = {
entityLabel: 'Datensatz',
createdLabel: 'Gesendet am',
resolvedLabel: 'Gelöst am',
reopenedLabel: 'Wieder geöffnet am',
markResolved: 'Als gelöst markieren',
reopen: 'Wieder öffnen',
reopenRequest:
'Du hast dieses Ticket wieder geöffnet. Bitte beschreibe kurz, was noch fehlt oder nicht stimmt — dann schaue ich es mir erneut an.',
edit: 'Bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Wird gespeichert …',
delete: 'Löschen',
confirmDelete: 'Diesen Fehlerbericht wirklich löschen?',
confirmDelete: 'Dieses Ticket in den Papierkorb verschieben? Du kannst es unter „Gelöscht" wiederherstellen.',
editTitle: 'Fehlerbericht bearbeiten',
editLabel: 'Beschreibung',
saved: 'Änderung gespeichert.',
resolvedToast: 'Ticket als gelöst markiert.',
reopenedToast: 'Ticket wieder geöffnet.',
deletedToast: 'Ticket gelöscht.',
deletedToast: 'Ticket in den Papierkorb verschoben.',
restore: 'Wiederherstellen',
restoredToast: 'Ticket wiederhergestellt.',
restoreError: 'Ticket konnte nicht wiederhergestellt werden.',
/** Countdown im Papierkorb bis zur endgültigen Löschung. {n} = verbleibende Tage. */
trashCountdownDays: 'Wird in {n} Tagen endgültig gelöscht.',
trashCountdownTomorrow: 'Wird morgen endgültig gelöscht.',
trashCountdownToday: 'Wird heute endgültig gelöscht.',
deletedLabel: 'Gelöscht am',
updateError: 'Ticket konnte nicht aktualisiert werden.',
deleteError: 'Ticket konnte nicht gelöscht werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
@@ -1257,11 +1268,14 @@ export const de = {
open: 'Offen',
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
closed: 'Geschlossen',
/** Gruppe „Gelöscht": in den Papierkorb verschobene Tickets (wiederherstellbar). */
deleted: 'Gelöscht',
},
/** Leertext je gefilterter Ansicht. */
emptyDialog: 'Aktuell keine Tickets im Dialog.',
emptyOpen: 'Keine offenen Tickets.',
emptyClosed: 'Noch keine geschlossenen Tickets.',
emptyDeleted: 'Der Papierkorb ist leer.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */