feat(hilfe): Eigene Tickets — Fehlerberichte ansehen, bearbeiten, löschen, lösen
Unter Hilfe (/hilfe/tickets) eine Verwaltung der eingereichten „Fehler melden"-
Berichte: Liste (neueste zuerst) mit Status-Badge (Offen / ✓ Gelöst), Kontext-
Label, betroffenem Objekt, Nachricht und Datum. Pro Ticket: als gelöst markieren/
wieder öffnen, Nachricht inline bearbeiten, löschen (mit Bestätigung).
Backend: Feedback um Status ("Open"/"Resolved", Default Open) + nullable ResolvedAt
erweitert (weiterhin FK-frei → übersteht Ingest-Wipe); Migration AddFeedbackStatus.
Endpoints: GET /feedback (neueste zuerst, inkl. Status), PUT /feedback/{id}
(Nachricht/Status; leer→400, fehlt→404; lösen setzt/öffnen löscht ResolvedAt),
DELETE /feedback/{id} (fehlt→404).
Tests: FeedbackEndpointTests (CRUD + Status-Lebenszyklus + übersteht Ingest),
e2e tickets.spec.ts; dotnet 215, vitest 129, playwright grün.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
204
gerbil-manager-web/src/pages/TicketsPage.tsx
Normal file
204
gerbil-manager-web/src/pages/TicketsPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* FEEDBACK-TICKETS: "Meine Tickets" — verwaltet die eingereichten "Fehler melden"-Berichte.
|
||||
*
|
||||
* Erreichbar über die Hilfe-Seite (/hilfe → „Meine Tickets") unter /hilfe/tickets.
|
||||
* 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.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import {
|
||||
listFeedback,
|
||||
updateFeedback,
|
||||
deleteFeedback,
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import './tickets.css'
|
||||
|
||||
const t = de.feedback.tickets
|
||||
const ctx = de.feedback.contexts
|
||||
const nav = de.nav
|
||||
|
||||
/** Kontext-Label (Ansicht); fällt für unbekannte Werte auf den Rohwert zurück. */
|
||||
function contextLabel(context: string): string {
|
||||
return (ctx as Record<string, string>)[context] ?? context
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function TicketsPage() {
|
||||
const toast = useToast()
|
||||
const tickets = useApi(() => listFeedback(), [])
|
||||
|
||||
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)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.updateError)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveMessage(ticket: FeedbackTicket, message: string) {
|
||||
await updateFeedback(ticket.id, { message })
|
||||
tickets.reload()
|
||||
toast.success(t.saved)
|
||||
}
|
||||
|
||||
async function handleDelete(ticket: FeedbackTicket) {
|
||||
if (!window.confirm(t.confirmDelete)) return
|
||||
try {
|
||||
await deleteFeedback(ticket.id)
|
||||
tickets.reload()
|
||||
toast.success(t.deletedToast)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.deleteError)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tickets.data
|
||||
|
||||
return (
|
||||
<section className="page tickets-page">
|
||||
<nav className="tickets-breadcrumb" aria-label="Brotkrümel">
|
||||
<Link to="/hilfe">{nav.help}</Link>
|
||||
<span aria-hidden="true"> › </span>
|
||||
<span>{t.title}</span>
|
||||
</nav>
|
||||
<h2>{t.title}</h2>
|
||||
<p className="tickets-subtitle">{t.subtitle}</p>
|
||||
|
||||
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
|
||||
|
||||
{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)}
|
||||
onDelete={() => handleDelete(ticket)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface TicketCardProps {
|
||||
ticket: FeedbackTicket
|
||||
onToggleStatus: () => void
|
||||
onSaveMessage: (message: string) => Promise<void>
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketCardProps) {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(ticket.message)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const resolved = ticket.status === 'Resolved'
|
||||
|
||||
function startEdit() {
|
||||
setDraft(ticket.message)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) {
|
||||
toast.error(t.emptyMessage)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSaveMessage(trimmed)
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.updateError)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`ticket-card${resolved ? ' ticket-card--resolved' : ''}`}>
|
||||
<div className="ticket-card__top">
|
||||
<span
|
||||
className={`ticket-badge ${resolved ? 'ticket-badge--resolved' : 'ticket-badge--open'}`}
|
||||
>
|
||||
{resolved ? `✓ ${t.statusResolved}` : t.statusOpen}
|
||||
</span>
|
||||
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
|
||||
{ticket.entityName && <span className="ticket-card__entity">{ticket.entityName}</span>}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="ticket-card__edit">
|
||||
<label className="ticket-card__edit-label" htmlFor={`ticket-edit-${ticket.id}`}>
|
||||
{t.editLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id={`ticket-edit-${ticket.id}`}
|
||||
className="ticket-card__textarea"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="ticket-card__actions">
|
||||
<button type="button" className="btn" onClick={() => setEditing(false)} disabled={saving}>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button type="button" className="btn btn--primary" onClick={save} disabled={saving}>
|
||||
{saving ? t.saving : t.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="ticket-card__message">{ticket.message}</p>
|
||||
<dl className="ticket-card__meta">
|
||||
<div>
|
||||
<dt>{t.createdLabel}</dt>
|
||||
<dd>{formatDate(ticket.createdAt)}</dd>
|
||||
</div>
|
||||
{resolved && (
|
||||
<div>
|
||||
<dt>{t.resolvedLabel}</dt>
|
||||
<dd>{formatDate(ticket.resolvedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<div className="ticket-card__actions">
|
||||
<button type="button" className="btn" onClick={onToggleStatus}>
|
||||
{resolved ? t.reopen : t.markResolved}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={startEdit}>
|
||||
{t.edit}
|
||||
</button>
|
||||
<button type="button" className="btn btn--danger" onClick={onDelete}>
|
||||
{t.delete}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user