feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf)

Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren"
(Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in
der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und
sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel,
User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle.

Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints
POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest
NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab).

Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe);
e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:30:42 +02:00
parent 1845d37476
commit b9e5b031c4
20 changed files with 2586 additions and 2 deletions

View File

@@ -0,0 +1,85 @@
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const f = de.feedback
const st = de.pages.stammbaum
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
// Debug-Kontext ist sichtbar (automatisch erfasst).
await expect(dialog).toContainText(f.debugTitle)
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
// Beschreibung eintragen + senden.
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
// Erfolgs-Toast + Dialog schließt.
await expect(page.getByText(f.success)).toBeVisible()
await expect(dialog).not.toBeVisible()
// Der Bericht wurde mit Debug-Kontext gespeichert.
expect(mockDb).not.toBeNull()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).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')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['litter-detail'])
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
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' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Rechtsklick auf die Wurzelkarte (Krümel).
await page.locator('.pedigree-card--root').click({ button: 'right' })
const menu = page.locator('.stammbaum-context-menu')
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts.stammbaum)
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
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' })
})

View File

@@ -397,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, result)
}
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = {
id: newId('feedback'),
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -76,6 +76,8 @@ export interface MockDb {
saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
}
function gerbil(
@@ -324,5 +326,6 @@ export function seedDb(): MockDb {
contracts: [],
saleAdConfigured: true,
namesConfigured: true,
feedback: [],
}
}

View File

@@ -0,0 +1,35 @@
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
import { api } from './client'
const RESOURCE = '/feedback'
/** Which view a report was filed from (matches the backend Context contract). */
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail'
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
export interface FeedbackInput {
message: string
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
entityName?: string | null
url?: string | null
clientTimestamp?: string | null
}
export interface Feedback {
id: string
message: string
context: string
gerbilId: string | null
litterId: string | null
entityName: string | null
url: string | null
clientTimestamp: string | null
userAgent: string | null
createdAt: string
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
return api.post<Feedback>(RESOURCE, body)
}

View File

@@ -0,0 +1,171 @@
/**
* FEEDBACK: "Fehler melden" — a small accessible modal with a textarea + submit.
*
* The caller passes the debug context (which view, the gerbil/litter id + name); the
* dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system.
*/
import { useEffect, useRef, useState } from 'react'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { submitFeedback, type FeedbackContext } from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
entityName?: string | null
}
interface ReportErrorDialogProps {
open: boolean
onClose: () => void
context: ReportErrorContext
}
/**
* Thin wrapper: mounts the dialog body only while open, so its form state starts
* fresh on every open (no reset-in-effect needed).
*/
export default function ReportErrorDialog({ open, onClose, context }: ReportErrorDialogProps) {
if (!open) return null
return <ReportErrorDialogBody onClose={onClose} context={context} />
}
function ReportErrorDialogBody({
onClose,
context,
}: {
onClose: () => void
context: ReportErrorContext
}) {
const t = de.feedback
const toast = useToast()
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Focus the textarea once on mount.
useEffect(() => {
const id = window.setTimeout(() => textareaRef.current?.focus(), 0)
return () => window.clearTimeout(id)
}, [])
// Close on Escape.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const contextLabel = t.contexts[context.context]
async function handleSubmit() {
const trimmed = message.trim()
if (!trimmed) {
toast.error(t.emptyMessage)
textareaRef.current?.focus()
return
}
setSubmitting(true)
try {
await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
litterId: context.litterId ?? null,
entityName: context.entityName ?? null,
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
toast.success(t.success)
onClose()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.error)
setSubmitting(false)
}
}
return (
<div
className="report-error__backdrop"
onClick={onClose}
role="presentation"
>
<div
className="report-error__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="report-error-title"
onClick={(e) => e.stopPropagation()}
>
<div className="report-error__header">
<h2 id="report-error-title" className="report-error__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="report-error__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
<p className="report-error__intro">{t.intro}</p>
<label className="report-error__label" htmlFor="report-error-message">
{t.label}
</label>
<textarea
id="report-error-message"
ref={textareaRef}
className="report-error__textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={t.placeholder}
rows={5}
/>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">
<div className="report-error__debug-row">
<dt>{t.debugFields.context}</dt>
<dd>{contextLabel}</dd>
</div>
{context.entityName && (
<div className="report-error__debug-row">
<dt>{t.debugFields.entity}</dt>
<dd>{context.entityName}</dd>
</div>
)}
<div className="report-error__debug-row">
<dt>{t.debugFields.url}</dt>
<dd className="report-error__debug-url">{window.location.href}</dd>
</div>
</dl>
</div>
<div className="report-error__actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>
{t.cancel}
</button>
<button
type="button"
className="btn btn--primary"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? t.submitting : t.submit}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
/* FEEDBACK: "Fehler melden" modal dialog. */
.report-error__backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(43, 33, 25, 0.45);
}
.report-error__dialog {
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
padding: 1.25rem;
animation: reportErrorIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes reportErrorIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.report-error__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.report-error__title {
margin: 0;
font-size: 1.15rem;
}
.report-error__close {
background: none;
border: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, #666);
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.report-error__close:hover {
background: var(--color-border, #eee);
}
.report-error__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.report-error__label {
display: block;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
.report-error__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: 5.5rem;
background: var(--color-bg, #fff);
color: var(--color-text);
}
.report-error__textarea:focus {
outline: 2px solid var(--color-accent, #2563eb);
outline-offset: 1px;
}
.report-error__debug {
margin-top: 0.9rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 8px;
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
}
.report-error__debug-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.report-error__debug-list {
margin: 0;
display: grid;
gap: 0.25rem;
}
.report-error__debug-row {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.report-error__debug-row dt {
flex: 0 0 5.5rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.report-error__debug-row dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.report-error__debug-url {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
}
.report-error__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -14,6 +14,7 @@ import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
import GerbilWeightTab from '../components/GerbilWeightTab'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast'
import './rennmausakte.css'
@@ -57,6 +58,7 @@ export default function GerbilDetailPage() {
const toast = useToast()
const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos')
const [reportOpen, setReportOpen] = useState(false)
const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
@@ -256,6 +258,9 @@ export default function GerbilDetailPage() {
{de.pages.vertraege.wizard.title}
</Link>
)}
<button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/rennmaeuse" className="ak-btn">
{t.detail.back}
</Link>
@@ -471,6 +476,12 @@ export default function GerbilDetailPage() {
</div>
</section>
</div>
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }}
/>
</section>
)
}

View File

@@ -16,7 +16,7 @@
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
@@ -29,6 +29,8 @@ import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog, { type ReportErrorContext } from '../components/ReportErrorDialog'
import { useToast } from '../components/toast'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import {
DEFAULT_GENERATIONS,
@@ -90,6 +92,64 @@ export default function StammbaumPage() {
const t = de.pages.stammbaum
const { id = '' } = useParams()
const navigate = useNavigate()
const toast = useToast()
/* ── Rechtsklick-Kontextmenü auf einer Ahnenkarte ── */
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
gerbil: Gerbil
} | null>(null)
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
const openContextMenu = useCallback((e: ReactMouseEvent, gerbil: Gerbil) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, gerbil })
}, [])
const closeContextMenu = useCallback(() => setContextMenu(null), [])
const copyId = useCallback(
async (gerbilId: string) => {
closeContextMenu()
try {
await navigator.clipboard.writeText(gerbilId)
toast.success(de.feedback.idCopied)
} catch {
toast.error(de.feedback.idCopyFailed)
}
},
[closeContextMenu, toast],
)
const openReport = useCallback(
(gerbil: Gerbil) => {
closeContextMenu()
setReportContext({
context: 'stammbaum',
gerbilId: gerbil.id,
entityName: gerbil.name || de.pages.gerbils.nameless,
})
},
[closeContextMenu],
)
/* Menü schließt bei Klick/Scroll/Escape außerhalb. */
useEffect(() => {
if (!contextMenu) return
const onAway = () => closeContextMenu()
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu()
}
window.addEventListener('click', onAway)
window.addEventListener('scroll', onAway, true)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('click', onAway)
window.removeEventListener('scroll', onAway, true)
window.removeEventListener('keydown', onKey)
}
}, [contextMenu, closeContextMenu])
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
const [source] = useState(createApiPedigreeSource)
@@ -288,13 +348,14 @@ export default function StammbaumPage() {
farbschlag={farbschlagOf(node.gerbil)}
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)}
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
/>
)}
</foreignObject>
</g>
)
},
[nodesByPath, farbschlagOf, navigate, handleExpand, t],
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, t],
)
/* ── Zustände: Laden / Fehler ── */
@@ -388,6 +449,42 @@ export default function StammbaumPage() {
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
{contextMenu && (
<ul
className="stammbaum-context-menu"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => copyId(contextMenu.gerbil.id)}
>
{t.contextMenu.copyId}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => openReport(contextMenu.gerbil)}
>
{t.contextMenu.reportError}
</button>
</li>
</ul>
)}
<ReportErrorDialog
open={reportContext !== null}
onClose={() => setReportContext(null)}
context={reportContext ?? { context: 'stammbaum' }}
/>
</>
)
}
@@ -400,12 +497,14 @@ function PedigreeCard({
farbschlag,
onOpen,
onExpand,
onContextMenu,
}: {
node: AnimalNode
isRoot: boolean
farbschlag: string | null
onOpen: () => void
onExpand: () => void
onContextMenu: (e: ReactMouseEvent) => void
}) {
const t = de.pages.stammbaum
const g = node.gerbil
@@ -422,6 +521,7 @@ function PedigreeCard({
<div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen}
onContextMenu={onContextMenu}
role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint}
>

View File

@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css'
@@ -64,6 +65,7 @@ export default function WurfDetailPage() {
const t = de.pages.litters
const gerbilName = useGerbilName()
const { id = '' } = useParams()
const [reportOpen, setReportOpen] = useState(false)
const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null
@@ -151,6 +153,9 @@ export default function WurfDetailPage() {
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/wuerfe" className="btn">
{t.detail.back}
</Link>
@@ -224,6 +229,12 @@ export default function WurfDetailPage() {
) : (
<p className="muted">{t.detail.needParentsGenotype}</p>
)}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
/>
</section>
)
}

View File

@@ -488,3 +488,38 @@
font-size: 0.8em;
color: #555;
}
/* FEEDBACK: Rechtsklick-Kontextmenü auf einer Ahnenkarte. */
.stammbaum-context-menu {
position: fixed;
z-index: 150;
margin: 0;
padding: 0.25rem;
list-style: none;
min-width: 11rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(43, 33, 25, 0.22);
}
.stammbaum-context-menu__item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
font: inherit;
font-size: 0.9rem;
color: var(--color-text);
padding: 0.5rem 0.7rem;
border-radius: 5px;
cursor: pointer;
}
.stammbaum-context-menu__item:hover,
.stammbaum-context-menu__item:focus-visible {
background: var(--color-accent, #2563eb);
color: #fff;
outline: none;
}

View File

@@ -438,6 +438,11 @@ export const de = {
createdOn: 'Erstellt am',
born: 'geb.',
},
/** FEEDBACK: Rechtsklick-Kontextmenü auf einer Stammbaum-Karte. */
contextMenu: {
copyId: 'ID kopieren',
reportError: 'Fehler melden',
},
},
// ── FEAT-7 (Kelly): Statistik & Berichte ──
statistik: {
@@ -842,6 +847,37 @@ export const de = {
unknown: 'Ein unbekannter Fehler ist aufgetreten.',
},
},
// ── FEEDBACK: "Fehler melden" Dialog + "ID kopieren" Aktion ──
feedback: {
/** Button-Beschriftung (Tierakte, Wurf-Ansicht). */
button: 'Fehler melden',
dialogTitle: 'Fehler melden',
intro: 'Beschreibe kurz, was nicht stimmt. Technische Angaben werden automatisch mitgesendet.',
label: 'Beschreibung',
placeholder: 'Was funktioniert nicht oder ist falsch dargestellt?',
/** Überschrift über den automatisch erfassten Debug-Angaben. */
debugTitle: 'Automatisch erfasst',
debugFields: {
context: 'Ansicht',
entity: 'Datensatz',
url: 'Adresse',
},
contexts: {
stammbaum: 'Stammbaum',
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
cancel: 'Abbrechen',
close: 'Schließen',
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
},
common: {
loading: 'Lädt …',
retry: 'Erneut versuchen',