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:
@@ -431,6 +431,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
reopenedAt: null,
|
||||
deletedAt: null,
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
@@ -440,6 +442,18 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
|
||||
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
|
||||
if (restoreMatch) {
|
||||
const fid = decodeURIComponent(restoreMatch[1])
|
||||
const row = db.feedback.find((f) => f.id === fid)
|
||||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'POST') {
|
||||
row.deletedAt = null
|
||||
return json(route, 200, row)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE.
|
||||
const fm = path.match(/^\/feedback\/([^/]+)$/)
|
||||
if (fm) {
|
||||
@@ -456,6 +470,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
agentContext?: string
|
||||
}
|
||||
const row = db.feedback[idx]
|
||||
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
|
||||
const wasResolved = row.status === 'Resolved'
|
||||
const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim()
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
@@ -504,6 +521,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
? s
|
||||
: 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
// „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage
|
||||
// wieder geöffnet wird (s. Backend).
|
||||
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||
row.reopenedAt = new Date().toISOString()
|
||||
}
|
||||
// Changelog (fixNote) — laienverständlich, sichtbar.
|
||||
if (typeof body.fixNote === 'string') {
|
||||
@@ -518,7 +539,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
db.feedback.splice(idx, 1)
|
||||
// Soft-Delete: in den Papierkorb verschieben (nicht entfernen).
|
||||
const target = db.feedback[idx]
|
||||
if (!target.deletedAt) target.deletedAt = new Date().toISOString()
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
|
||||
@@ -188,7 +188,9 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
@@ -200,8 +202,50 @@ test.describe('Meine Tickets', () => {
|
||||
acceptNextDialog(page)
|
||||
await card.getByRole('button', { name: tt.delete }).click()
|
||||
|
||||
// Aus „Geschlossen" verschwunden …
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
|
||||
await selectView(page, tt.filters.deleted)
|
||||
const trashed = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(trashed).toBeVisible()
|
||||
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
|
||||
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
|
||||
|
||||
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
|
||||
await trashed.getByRole('button', { name: tt.restore }).click()
|
||||
await expect(
|
||||
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
|
||||
).toHaveCount(0)
|
||||
await selectView(page, tt.filters.closed)
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await card.getByRole('button', { name: tt.reopen }).click()
|
||||
|
||||
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
|
||||
await selectView(page, tt.filters.dialog)
|
||||
const reopened = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
|
||||
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
|
||||
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`, {})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user