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:
85
gerbil-manager-web/e2e/feedback.spec.ts
Normal file
85
gerbil-manager-web/e2e/feedback.spec.ts
Normal 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' })
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
35
gerbil-manager-web/src/api/feedback.ts
Normal file
35
gerbil-manager-web/src/api/feedback.ts
Normal 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)
|
||||
}
|
||||
171
gerbil-manager-web/src/components/ReportErrorDialog.tsx
Normal file
171
gerbil-manager-web/src/components/ReportErrorDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
gerbil-manager-web/src/components/reportErrorDialog.css
Normal file
149
gerbil-manager-web/src/components/reportErrorDialog.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user