feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern

- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:02:11 +02:00
parent 9c98c37d2a
commit 438c816820
27 changed files with 3474 additions and 34 deletions

View File

@@ -56,6 +56,24 @@ test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wur
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
})
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
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['contact-detail'])
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
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' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')

View File

@@ -195,7 +195,16 @@ export function seedDb(): MockDb {
const litters: Litter[] = [
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -212,7 +221,16 @@ export function seedDb(): MockDb {
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
const contacts: Contact[] = [
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
{
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
}),
},
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },

View File

@@ -44,3 +44,42 @@ test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ pa
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(p.empty)
})
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('als Züchter erkannt')
await expect(dialog).toContainText('als Abnehmer erkannt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('aus Wurfchronik')
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})

View File

@@ -4,7 +4,7 @@ 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'
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
export interface FeedbackInput {
@@ -12,6 +12,7 @@ export interface FeedbackInput {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
url?: string | null
clientTimestamp?: string | null
@@ -23,6 +24,7 @@ export interface Feedback {
context: string
gerbilId: string | null
litterId: string | null
contactId: string | null
entityName: string | null
url: string | null
clientTimestamp: string | null

View File

@@ -90,6 +90,13 @@ export interface GerbilProvenance {
notes: string[]
}
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines beliebigen importierten Eintrags
* (Tier, Kontakt oder Wurf). {@link GerbilProvenance} ist die Tier-Spezialisierung
* mit zusätzlichen Eltern-Feldern; Kontakte/Würfe nutzen dieselbe Grundform.
*/
export type EntityProvenance = GerbilProvenance
/** Payload for POST /gerbils. */
export interface CreateGerbil {
name: string
@@ -142,6 +149,11 @@ export interface Contact {
isReceiver: boolean
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
nameSuffix: string | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
}
export interface Litter {
@@ -162,6 +174,11 @@ export interface Litter {
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
}
/** Payload for POST /litters. */

View File

@@ -9,21 +9,23 @@
*/
import { useEffect } from 'react'
import { de } from '../strings/de'
import type { GerbilProvenance } from '../api/types'
import type { EntityProvenance } from '../api/types'
import './provenanceDialog.css'
interface ProvenanceDialogProps {
open: boolean
onClose: () => void
/** Raw provenance JSON string from Gerbil.provenance (null = no import data). */
/** Raw provenance JSON string from the entity (Gerbil/Contact/Litter; null = no import data). */
provenance?: string | null
/** Optional entity label (e.g. "dieses Tiers", "dieses Kontakts") for the intro line. */
entityLabel?: string
}
/** Parse the JSON provenance string defensively; null on absence / parse error. */
function parseProvenance(raw?: string | null): GerbilProvenance | null {
function parseProvenance(raw?: string | null): EntityProvenance | null {
if (!raw || !raw.trim()) return null
try {
const p = JSON.parse(raw) as Partial<GerbilProvenance>
const p = JSON.parse(raw) as Partial<EntityProvenance>
return {
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
@@ -37,7 +39,7 @@ function parseProvenance(raw?: string | null): GerbilProvenance | null {
}
}
export default function ProvenanceDialog({ open, onClose, provenance }: ProvenanceDialogProps) {
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
// Close on Escape (only while mounted).
useEffect(() => {
if (!open) return
@@ -84,7 +86,7 @@ export default function ProvenanceDialog({ open, onClose, provenance }: Provenan
<p className="provenance__empty">{t.empty}</p>
) : (
<>
<p className="provenance__intro">{t.intro}</p>
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
<section className="provenance__section">
<div className="provenance__section-title">

View File

@@ -16,6 +16,7 @@ export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
}
@@ -78,6 +79,7 @@ function ReportErrorDialogBody({
context: context.context,
gerbilId: context.gerbilId ?? null,
litterId: context.litterId ?? null,
contactId: context.contactId ?? null,
entityName: context.entityName ?? null,
url: window.location.href,
clientTimestamp: new Date().toISOString(),

View File

@@ -12,12 +12,16 @@ import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
export default function KontaktDetailPage() {
const t = de.pages.kontakte
const { id = '' } = useParams()
const navigate = useNavigate()
const [deleteError, setDeleteError] = useState<string | null>(null)
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const contact = useApi(() => getContact(id), [id])
// Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |).
@@ -79,6 +83,12 @@ export default function KontaktDetailPage() {
<Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<button
type="button"
className="btn btn--danger"
@@ -161,6 +171,19 @@ export default function KontaktDetailPage() {
))}
</ul>
)}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'contact-detail', contactId: c.id, entityName: c.name }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={c.provenance}
entityLabel={de.provenance.entityLabels.contact}
/>
</section>
)
}

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 ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css'
@@ -66,6 +67,7 @@ export default function WurfDetailPage() {
const gerbilName = useGerbilName()
const { id = '' } = useParams()
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null
@@ -153,6 +155,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={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
@@ -235,6 +240,13 @@ export default function WurfDetailPage() {
onClose={() => setReportOpen(false)}
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={l.provenance}
entityLabel={de.provenance.entityLabels.litter}
/>
</section>
)
}

View File

@@ -866,6 +866,7 @@ export const de = {
stammbaum: 'Stammbaum',
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
'contact-detail': 'Kontakt',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
@@ -884,6 +885,14 @@ export const de = {
button: 'Datenherkunft',
dialogTitle: 'Nachverfolgungsinformationen',
intro: 'Woher stammen die Daten dieses Eintrags? Diese Angaben werden beim Import automatisch erfasst.',
/** Wie {@link intro}, aber mit Entitätsbezeichnung (z. B. „dieses Kontakts"). */
introFor: (label: string) =>
`Woher stammen die Daten ${label}? Diese Angaben werden beim Import automatisch erfasst.`,
/** Entitätsbezeichnungen für introFor (Genitiv). */
entityLabels: {
contact: 'dieses Kontakts',
litter: 'dieses Wurfs',
},
/** Überschriften / Feldbeschriftungen. */
sourceFilesTitle: 'Quelldateien',
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),