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:
2026-06-22 17:52:50 +02:00
parent e1299a1a63
commit ac06cadb23
18 changed files with 2359 additions and 23 deletions

View File

@@ -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) => {

View File

@@ -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-]+)(?:\/([^/]+))?$/)

View File

@@ -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,
},
],
}
}

View 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)
})
})

View File

@@ -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">

View File

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

View File

@@ -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">

View 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>
)
}

View File

@@ -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;

View 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;
}

View File

@@ -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: {