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:
@@ -25,17 +25,17 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg',
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
// Der Bericht wurde mit Debug-Kontext gespeichert.
|
||||
// Der Bericht wurde mit Debug-Kontext gespeichert (zuletzt eingereichter Eintrag;
|
||||
// die Mock-DB ist mit Beispiel-Tickets vorbefüllt).
|
||||
expect(mockDb).not.toBeNull()
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Der Farbschlag stimmt nicht.')
|
||||
expect(report).toMatchObject({
|
||||
message: 'Der Farbschlag stimmt nicht.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'kruemel',
|
||||
})
|
||||
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
|
||||
expect(typeof reports[0].clientTimestamp).toBe('string')
|
||||
expect(report!.url).toContain('/rennmaeuse/kruemel')
|
||||
expect(typeof report!.clientTimestamp).toBe('string')
|
||||
})
|
||||
|
||||
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
|
||||
@@ -51,9 +51,8 @@ test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wur
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Die Wurfstärke ist falsch.')
|
||||
expect(report).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
|
||||
})
|
||||
|
||||
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
|
||||
@@ -69,9 +68,8 @@ test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Die Adresse stimmt nicht.')
|
||||
expect(report).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
|
||||
})
|
||||
|
||||
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
|
||||
@@ -97,9 +95,8 @@ test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
||||
const report = mockDb!.feedback.find((r) => r.message === 'Stammbaum-Bug.')
|
||||
expect(report).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
||||
})
|
||||
|
||||
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
|
||||
|
||||
@@ -407,6 +407,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
...body,
|
||||
userAgent: request.headers()['user-agent'] ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'Open',
|
||||
resolvedAt: null,
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
@@ -416,6 +418,32 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE.
|
||||
const fm = path.match(/^\/feedback\/([^/]+)$/)
|
||||
if (fm) {
|
||||
const fid = decodeURIComponent(fm[1])
|
||||
const idx = db.feedback.findIndex((f) => f.id === fid)
|
||||
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { message?: string; status?: string }
|
||||
const row = db.feedback[idx]
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
}
|
||||
if (typeof body.status === 'string') {
|
||||
const resolved = body.status.toLowerCase() === 'resolved'
|
||||
row.status = resolved ? 'Resolved' : 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
}
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
db.feedback.splice(idx, 1)
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
|
||||
@@ -391,6 +391,39 @@ export function seedDb(): MockDb {
|
||||
],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
feedback: [],
|
||||
// FEEDBACK-TICKETS: ein offenes + ein gelöstes Ticket (GET liefert neueste zuerst →
|
||||
// db.feedback wird im Mock umgekehrt; das offene steht hier zuletzt = erscheint oben).
|
||||
feedback: [
|
||||
{
|
||||
id: 'feedback-resolved',
|
||||
message: 'Der Stammbaum zeigt den falschen Vater bei Krümel.',
|
||||
context: 'stammbaum',
|
||||
gerbilId: 'kruemel',
|
||||
litterId: null,
|
||||
contactId: null,
|
||||
entityName: 'Krümel',
|
||||
url: 'http://localhost:5173/rennmaeuse/kruemel/stammbaum',
|
||||
clientTimestamp: '2026-06-10T09:00:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-10T09:00:00Z',
|
||||
status: 'Resolved',
|
||||
resolvedAt: '2026-06-12T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'feedback-open',
|
||||
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'fridolin',
|
||||
litterId: null,
|
||||
contactId: null,
|
||||
entityName: 'Fridolin',
|
||||
url: 'http://localhost:5173/rennmaeuse/fridolin',
|
||||
clientTimestamp: '2026-06-15T11:30:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-15T11:30:00Z',
|
||||
status: 'Open',
|
||||
resolvedAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
75
gerbil-manager-web/e2e/tickets.spec.ts
Normal file
75
gerbil-manager-web/e2e/tickets.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
|
||||
* Erreichbar über Hilfe → „Meine Tickets". Liste mit Status-Badges (Offen/Gelöst),
|
||||
* gelöst markieren / wieder öffnen, Nachricht bearbeiten, löschen.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
|
||||
|
||||
const tt = de.feedback.tickets
|
||||
|
||||
test.describe('Meine Tickets', () => {
|
||||
test.beforeEach(() => skipUnlessMock())
|
||||
|
||||
test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => {
|
||||
await gotoSection(page, de.nav.help)
|
||||
await page.getByRole('link', { name: tt.link }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: tt.title })).toBeVisible()
|
||||
|
||||
// Offenes Ticket (Badge „Offen") + dessen Nachricht.
|
||||
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
|
||||
|
||||
// Gelöstes Ticket (Badge mit Häkchen + „Gelöst").
|
||||
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
|
||||
await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket als gelöst markieren', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
const openCard = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
|
||||
await expect(openCard.getByText(tt.statusOpen, { exact: true })).toBeVisible()
|
||||
|
||||
await openCard.getByRole('button', { name: tt.markResolved }).click()
|
||||
|
||||
// Badge wechselt auf „Gelöst", Aktion bietet jetzt „Wieder öffnen".
|
||||
// Auf das Badge (.ticket-badge) zielen — „Gelöst" steht sonst auch im Meta-Label „Gelöst am".
|
||||
await expect(openCard.locator('.ticket-badge--resolved')).toBeVisible()
|
||||
await expect(openCard.getByRole('button', { name: tt.reopen })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
// Karte über den Datensatz-Namen (bleibt auch im Bearbeiten-Modus sichtbar)
|
||||
// adressieren — die Nachricht selbst wird beim Bearbeiten durch das Textfeld ersetzt.
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await card.getByRole('button', { name: tt.edit }).click()
|
||||
|
||||
const textarea = card.locator('textarea')
|
||||
await textarea.fill('Korrigierte Beschreibung des Fehlers.')
|
||||
await card.getByRole('button', { name: tt.save }).click()
|
||||
|
||||
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
acceptNextDialog(page)
|
||||
await card.getByRole('button', { name: tt.delete }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
|
||||
import StammbaumPage from './pages/StammbaumPage'
|
||||
import StatistikPage from './pages/StatistikPage'
|
||||
import HilfePage from './pages/HilfePage'
|
||||
import TicketsPage from './pages/TicketsPage'
|
||||
import FarbkatalogPage from './pages/FarbkatalogPage'
|
||||
import VertraegeListPage from './pages/VertraegeListPage'
|
||||
import VertragWizardPage from './pages/VertragWizardPage'
|
||||
@@ -70,7 +71,11 @@ export default function App() {
|
||||
<Route path="genetik" element={<GenetikPage />} />
|
||||
<Route path="abgabe" element={<AbgabePage />} />
|
||||
<Route path="statistik" element={<StatistikPage />} />
|
||||
<Route path="hilfe" element={<HilfePage />} />
|
||||
<Route path="hilfe">
|
||||
<Route index element={<HilfePage />} />
|
||||
{/* FEEDBACK-TICKETS: "Meine Tickets" — verwaltet eingereichte Fehlerberichte */}
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
</Route>
|
||||
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
||||
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
||||
<Route path="vertraege">
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface FeedbackInput {
|
||||
clientTimestamp?: string | null
|
||||
}
|
||||
|
||||
/** Ticket lifecycle status (matches the backend Status contract). */
|
||||
export type FeedbackStatus = 'Open' | 'Resolved'
|
||||
|
||||
export interface Feedback {
|
||||
id: string
|
||||
message: string
|
||||
@@ -30,8 +33,34 @@ export interface Feedback {
|
||||
clientTimestamp: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
status: FeedbackStatus
|
||||
resolvedAt: string | null
|
||||
}
|
||||
|
||||
/** Alias used by the "Meine Tickets" area for readability. */
|
||||
export type FeedbackTicket = Feedback
|
||||
|
||||
/** Payload for PUT /feedback/{id}: edit the message and/or toggle the status. */
|
||||
export interface FeedbackUpdate {
|
||||
message?: string
|
||||
status?: FeedbackStatus
|
||||
}
|
||||
|
||||
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
|
||||
return api.post<Feedback>(RESOURCE, body)
|
||||
}
|
||||
|
||||
/** List all feedback tickets, newest first. */
|
||||
export function listFeedback(): Promise<Feedback[]> {
|
||||
return api.get<Feedback[]>(RESOURCE)
|
||||
}
|
||||
|
||||
/** Edit a ticket's message and/or status. */
|
||||
export function updateFeedback(id: string, body: FeedbackUpdate): Promise<Feedback> {
|
||||
return api.put<Feedback>(`${RESOURCE}/${id}`, body)
|
||||
}
|
||||
|
||||
/** Delete a ticket. */
|
||||
export function deleteFeedback(id: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/${id}`)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Accordion via <details>/<summary> — kein JS-State nötig, barrierefrei, mobiltauglich.
|
||||
* Texte referenzieren nur Bezeichnungen aus de.ts (bleiben bei Umbenennung korrekt).
|
||||
*/
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import './hilfe.css'
|
||||
|
||||
@@ -218,6 +219,12 @@ export default function HilfePage() {
|
||||
<h2>{t.title}</h2>
|
||||
<p className="hilfe-subtitle">{t.subtitle}</p>
|
||||
|
||||
{/* FEEDBACK-TICKETS: Verwaltung der eingereichten Fehlerberichte */}
|
||||
<Link to="/hilfe/tickets" className="hilfe-tickets-link">
|
||||
{de.feedback.tickets.link}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
|
||||
<div className="hilfe-accordion">
|
||||
{sections.map((s) => (
|
||||
<details key={s.id} className="hilfe-section">
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,25 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* FEEDBACK-TICKETS: Link zur Ticket-Verwaltung */
|
||||
.hilfe-tickets-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-accent, #2563eb);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hilfe-tickets-link:hover {
|
||||
background: var(--color-hover, #f5f5f5);
|
||||
}
|
||||
|
||||
/* Accordion */
|
||||
.hilfe-accordion {
|
||||
display: flex;
|
||||
|
||||
169
gerbil-manager-web/src/pages/tickets.css
Normal file
169
gerbil-manager-web/src/pages/tickets.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/* FEEDBACK-TICKETS: "Meine Tickets" — Liste der eingereichten Fehlerberichte. */
|
||||
.tickets-page {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.tickets-breadcrumb {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-muted, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tickets-breadcrumb a {
|
||||
color: var(--color-accent, #2563eb);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tickets-breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tickets-subtitle {
|
||||
color: var(--color-muted, #666);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tickets-empty {
|
||||
color: var(--color-muted, #666);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tickets-error {
|
||||
color: var(--color-danger-text, #991b1b);
|
||||
background: var(--color-danger-bg, #fef2f2);
|
||||
border-left: 3px solid var(--color-danger, #ef4444);
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tickets-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-card {
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface, #fff);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.ticket-card--resolved {
|
||||
background: var(--color-surface-2, #f8faf8);
|
||||
border-color: var(--color-border, #e2e8e2);
|
||||
}
|
||||
|
||||
.ticket-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.ticket-badge {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-badge--open {
|
||||
background: var(--color-info-bg, #eff6ff);
|
||||
color: var(--color-info-text, #1e40af);
|
||||
}
|
||||
|
||||
.ticket-badge--resolved {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success-text, #166534);
|
||||
}
|
||||
|
||||
.ticket-card__context {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #333);
|
||||
}
|
||||
|
||||
.ticket-card__entity {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted, #666);
|
||||
}
|
||||
|
||||
.ticket-card__entity::before {
|
||||
content: '· ';
|
||||
}
|
||||
|
||||
.ticket-card__message {
|
||||
margin: 0 0 0.75rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ticket-card--resolved .ticket-card__message {
|
||||
color: var(--color-muted, #555);
|
||||
}
|
||||
|
||||
.ticket-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1.5rem;
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ticket-card__meta div {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ticket-card__meta dt {
|
||||
font-weight: 600;
|
||||
color: var(--color-muted, #777);
|
||||
}
|
||||
|
||||
.ticket-card__meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ticket-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ticket-card__edit-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.ticket-card__textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ticket-card__textarea:focus {
|
||||
outline: 2px solid var(--color-accent, #2563eb);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -882,6 +882,39 @@ export const de = {
|
||||
/** Toast nach erfolgreichem "ID kopieren". */
|
||||
idCopied: 'ID kopiert',
|
||||
idCopyFailed: 'ID konnte nicht kopiert werden.',
|
||||
/** "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte. */
|
||||
tickets: {
|
||||
title: 'Meine Tickets',
|
||||
subtitle: 'Deine eingereichten Fehlerberichte — bearbeite, schließe oder lösche sie hier.',
|
||||
/** Link auf der Hilfe-Seite. */
|
||||
link: 'Meine Tickets (Fehlerberichte)',
|
||||
empty: 'Du hast noch keine Fehlerberichte gesendet.',
|
||||
loadError: 'Tickets konnten nicht geladen werden.',
|
||||
statusOpen: 'Offen',
|
||||
statusResolved: 'Gelöst',
|
||||
/** Spalte/Label für die Ansicht, aus der der Bericht kam. */
|
||||
contextLabel: 'Ansicht',
|
||||
entityLabel: 'Datensatz',
|
||||
createdLabel: 'Gesendet am',
|
||||
resolvedLabel: 'Gelöst am',
|
||||
markResolved: 'Als gelöst markieren',
|
||||
reopen: 'Wieder öffnen',
|
||||
edit: 'Bearbeiten',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
saving: 'Wird gespeichert …',
|
||||
delete: 'Löschen',
|
||||
confirmDelete: 'Diesen Fehlerbericht wirklich löschen?',
|
||||
editTitle: 'Fehlerbericht bearbeiten',
|
||||
editLabel: 'Beschreibung',
|
||||
saved: 'Änderung gespeichert.',
|
||||
resolvedToast: 'Ticket als gelöst markiert.',
|
||||
reopenedToast: 'Ticket wieder geöffnet.',
|
||||
deletedToast: 'Ticket gelöscht.',
|
||||
updateError: 'Ticket konnte nicht aktualisiert werden.',
|
||||
deleteError: 'Ticket konnte nicht gelöscht werden.',
|
||||
emptyMessage: 'Bitte beschreibe den Fehler.',
|
||||
},
|
||||
},
|
||||
/** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */
|
||||
provenance: {
|
||||
|
||||
Reference in New Issue
Block a user