- 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>
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
/**
|
|
* NACHVERFOLGUNG: "Nachverfolgungsinformationen" — read-only modal showing the
|
|
* data provenance of an imported gerbil (which source files contributed, how many
|
|
* raw records were merged, how the parents were derived, and human-readable notes).
|
|
*
|
|
* The provenance arrives as a JSON string on the Gerbil DTO (produced by the Python
|
|
* merge step). We parse it here defensively; anything unparseable / absent is shown
|
|
* as "Keine Herkunftsdaten vorhanden." Mirrors the ReportErrorDialog modal pattern.
|
|
*/
|
|
import { useEffect } from 'react'
|
|
import { de } from '../strings/de'
|
|
import type { EntityProvenance } from '../api/types'
|
|
import './provenanceDialog.css'
|
|
|
|
interface ProvenanceDialogProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
/** 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): EntityProvenance | null {
|
|
if (!raw || !raw.trim()) return null
|
|
try {
|
|
const p = JSON.parse(raw) as Partial<EntityProvenance>
|
|
return {
|
|
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
|
|
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
|
|
fromWurfchronik: Boolean(p.fromWurfchronik),
|
|
parentMethod: p.parentMethod,
|
|
parentConfidence: p.parentConfidence,
|
|
notes: Array.isArray(p.notes) ? p.notes : [],
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
|
|
// Close on Escape (only while mounted).
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [open, onClose])
|
|
|
|
if (!open) return null
|
|
|
|
const t = de.provenance
|
|
const p = parseProvenance(provenance)
|
|
const method = p?.parentMethod ? (t.methods[p.parentMethod] ?? p.parentMethod) : null
|
|
const confidence = p?.parentConfidence
|
|
? (t.confidences[p.parentConfidence] ?? p.parentConfidence)
|
|
: null
|
|
|
|
return (
|
|
<div className="provenance__backdrop" onClick={onClose} role="presentation">
|
|
<div
|
|
className="provenance__dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="provenance-title"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="provenance__header">
|
|
<h2 id="provenance-title" className="provenance__title">
|
|
{t.dialogTitle}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
className="provenance__close"
|
|
onClick={onClose}
|
|
aria-label={t.close}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{!p ? (
|
|
<p className="provenance__empty">{t.empty}</p>
|
|
) : (
|
|
<>
|
|
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
|
|
|
|
<section className="provenance__section">
|
|
<div className="provenance__section-title">
|
|
{t.mergedTitle}
|
|
</div>
|
|
<p className="provenance__merged">{t.mergedCount(p.mergedRecordCount)}</p>
|
|
{p.fromWurfchronik && (
|
|
<p className="provenance__wurfchronik">📖 {t.fromWurfchronik}</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="provenance__section">
|
|
<div className="provenance__section-title">
|
|
{t.sourceFilesTitle} — {t.sourceFilesCount(p.sourceFiles.length)}
|
|
</div>
|
|
{p.sourceFiles.length === 0 ? (
|
|
<p className="provenance__empty">{t.empty}</p>
|
|
) : (
|
|
<ul className="provenance__files">
|
|
{p.sourceFiles.map((f) => (
|
|
<li key={f} className="provenance__file">
|
|
{f}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{(method || confidence) && (
|
|
<section className="provenance__section">
|
|
<div className="provenance__section-title">{t.parentsTitle}</div>
|
|
<dl className="provenance__kvlist">
|
|
{method && (
|
|
<div className="provenance__kv">
|
|
<dt>{t.parentMethodLabel}</dt>
|
|
<dd>{method}</dd>
|
|
</div>
|
|
)}
|
|
{confidence && (
|
|
<div className="provenance__kv">
|
|
<dt>{t.parentConfidenceLabel}</dt>
|
|
<dd>{confidence}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
</section>
|
|
)}
|
|
|
|
{p.notes.length > 0 && (
|
|
<section className="provenance__section">
|
|
<div className="provenance__section-title">{t.notesTitle}</div>
|
|
<ul className="provenance__notes">
|
|
{p.notes.map((n) => (
|
|
<li key={n}>{n}</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="provenance__actions">
|
|
<button type="button" className="btn btn--primary" onClick={onClose}>
|
|
{t.close}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|