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:
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user