import { useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { ArrowLeft, FileSpreadsheet, Loader2, Save, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { csvPreviewTdStyle } from '../utils/csvPreviewCells' const MODULE_LABEL = { nutrition: 'Ernährung', weight: 'Gewicht', blood_pressure: 'Blutdruck', activity: 'Aktivität', sleep: 'Schlaf', vitals_baseline: 'Vitalwerte (Baseline)', } /** Vorschläge für Freitext „Quelleinheit“ (Volumen, Stück …); Ziel kommt aus dem Datenmodell. */ const CUSTOM_SOURCE_UNIT_HINTS = [ 'ml', 'l', 'cl', 'dl', 'µl', 'mg', 'µg', 'Stück', 'Portion', ] /** Eine gemeinsame Funktion für alle nicht-Schlüssel-Zielfelder (pro-Feld-Mix = spätere Ausbaustufe). */ const ROW_AGG_OPS = [ { value: 'sum', label: 'Summe' }, { value: 'mean', label: 'Mittelwert' }, { value: 'min', label: 'Minimum' }, { value: 'max', label: 'Maximum' }, { value: 'median', label: 'Median' }, { value: 'first', label: 'Erster Wert (Reihenfolge in der Datei)' }, { value: 'last', label: 'Letzter Wert (Reihenfolge in der Datei)' }, ] const NUMERIC_ROW_AGG = new Set(['sum', 'mean', 'min', 'max', 'median']) /** Wenn mehrere CSV-Zeilen denselben group_by-Schlüssel haben */ const MULTI_ROW_POLICY_OPTIONS = [ { value: 'aggregate', label: 'Zusammenführen (Funktion unten auf alle übrigen Felder)' }, { value: 'reject', label: 'Abweisen — Gruppe wird nicht importiert (Fehlerhinweis im Import)' }, { value: 'first_row', label: 'Nur erste Zeile — keine Berechnung über die Duplikat-Zeilen' }, { value: 'last_row', label: 'Nur letzte Zeile — keine Berechnung über die Duplikat-Zeilen' }, ] function parseStoredImportRowProcessing(irp) { const safe = irp && typeof irp === 'object' ? irp : null const dedupeFromStore = !!(safe && safe.dedupe_identical_rows) const mrpRaw = safe && safe.multi_row_policy != null ? String(safe.multi_row_policy) : null const multiRowPolicy = mrpRaw && MULTI_ROW_POLICY_OPTIONS.some((o) => o.value === mrpRaw) ? mrpRaw : 'aggregate' if (!safe || Object.keys(safe).length === 0) { return { useCustom: false, irregular: false, groupBy: [], mode: '', multiRowPolicy: 'aggregate', dedupeIdentical: false, } } const gb = safe.group_by const agg = safe.aggregates if (!Array.isArray(gb) || gb.length === 0 || agg == null || typeof agg !== 'object') { return { useCustom: true, irregular: true, groupBy: Array.isArray(gb) ? [...gb] : [], mode: '', multiRowPolicy, dedupeIdentical: dedupeFromStore, } } const ops = [...new Set(Object.values(agg).map((x) => String(x)))] if (ops.length !== 1) { return { useCustom: true, irregular: true, groupBy: [...gb], mode: '', multiRowPolicy, dedupeIdentical: dedupeFromStore, } } return { useCustom: true, irregular: false, groupBy: [...gb], mode: ops[0], multiRowPolicy, dedupeIdentical: dedupeFromStore, } } function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPolicy, dedupeIdentical) { const targets = new Set( Object.values(fm).filter((v) => v && v !== '-' && v !== '_skip'), ) const aggregates = {} const gbSet = new Set(groupBy) for (const t of targets) { if (gbSet.has(t)) continue const typ = modFields?.[t]?.type if (mode === 'first' || mode === 'last') { aggregates[t] = mode } else if (NUMERIC_ROW_AGG.has(mode)) { if (typ === 'string') continue aggregates[t] = mode } } const out = { group_by: groupBy, aggregates, multi_row_policy: multiRowPolicy || 'aggregate', } if (dedupeIdentical) out.dedupe_identical_rows = true return out } /** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */ function normalizeDecimalInputString(raw) { let s = String(raw).trim().replace(/\s/g, '') if (s === '') return '' const lastComma = s.lastIndexOf(',') const lastDot = s.lastIndexOf('.') if (lastComma >= 0 && lastDot >= 0) { if (lastComma > lastDot) { s = s.replace(/\./g, '').replace(',', '.') } else { s = s.replace(/,/g, '') } } else if (lastComma >= 0) { s = s.replace(',', '.') } return s } /** * Semantik: „source_amount [source_unit_label] entspricht target_amount [canonical storage unit]“ * → CSV-Zahl (in der linken Einheit) × (target_amount / source_amount) → Speicherwert. * custom_equivalence dient der Dokumentation im JSON; der Importer nutzt nur conversion_factor. */ function loadEquivalenceFromTc(tc, fieldKey) { const row = tc[fieldKey] if (!row || typeof row !== 'object') { return { srcAmt: '1', srcUnit: '', tgtAmt: '' } } const eq = row.custom_equivalence if (eq && typeof eq === 'object') { const sa = eq.source_amount const ta = eq.target_amount return { srcAmt: sa != null && sa !== '' ? String(sa) : '', srcUnit: eq.source_unit_label != null ? String(eq.source_unit_label) : '', tgtAmt: ta != null && ta !== '' ? String(ta) : '', } } const f = row.conversion_factor if (f != null && f !== '') { return { srcAmt: '1', srcUnit: '', tgtAmt: String(f) } } return { srcAmt: '1', srcUnit: '', tgtAmt: '' } } function applyCustomEquivalenceToTcObject(tc, fieldKey, draft, canonicalUnitLabel) { const base = tc[fieldKey] && typeof tc[fieldKey] === 'object' ? { ...tc[fieldKey] } : { type: 'float', decimal_separator: 'auto', flexible: true } const sa = normalizeDecimalInputString(draft.srcAmt ?? '') const ta = normalizeDecimalInputString(draft.tgtAmt ?? '') const unitLbl = String(draft.srcUnit ?? '').trim() const bothEmpty = !sa && !ta if (bothEmpty) { delete base.conversion_factor delete base.custom_equivalence base.source_unit = 'custom' delete base.target_unit tc[fieldKey] = base return { ok: true } } if (!sa || !ta) { return { ok: true, partial: true } } const srcNum = Number(sa) const tgtNum = Number(ta) if (Number.isNaN(srcNum) || Number.isNaN(tgtNum)) { return { ok: false, message: `Umrechnung (${fieldKey}): keine gültigen Zahlen.` } } if (srcNum === 0) { return { ok: false, message: `Umrechnung (${fieldKey}): die linke Menge darf nicht 0 sein.` } } const factor = tgtNum / srcNum base.conversion_factor = factor base.source_unit = 'custom' base.custom_equivalence = { source_amount: srcNum, source_unit_label: unitLbl || '(Quelleinheit)', target_amount: tgtNum, target_unit_label: canonicalUnitLabel || '(Ziel)', } delete base.target_unit tc[fieldKey] = base return { ok: true } } function SampleTable({ sampleRows, columns }) { if (!sampleRows?.length || !columns?.length) return null const showCols = columns.slice(0, 8) return (
{showCols.map((c) => ( ))} {sampleRows.slice(0, 5).map((row, i) => ( {showCols.map((c) => ( ))} ))}
{c}
{row[c] ?? '—'}
) } export default function AdminCsvTemplateEditorPage() { const { id: routeId } = useParams() const navigate = useNavigate() const isNew = routeId === 'new' const templateId = isNew ? null : Number(routeId) const [modules, setModules] = useState([]) const [module, setModule] = useState('nutrition') const [mappingName, setMappingName] = useState('') const [description, setDescription] = useState('') const [delimiter, setDelimiter] = useState(';') const [encoding, setEncoding] = useState('utf-8') const [hasHeader, setHasHeader] = useState(true) const [columnSignature, setColumnSignature] = useState([]) const [columns, setColumns] = useState([]) const [fieldMappings, setFieldMappings] = useState({}) const [typeConversionsText, setTypeConversionsText] = useState('{}') const [sampleRows, setSampleRows] = useState([]) const [seedHint, setSeedHint] = useState(null) const [file, setFile] = useState(null) const [delimiterOverride, setDelimiterOverride] = useState('') const [seedTemplateId, setSeedTemplateId] = useState('') const [seedOptions, setSeedOptions] = useState([]) const [loading, setLoading] = useState(!isNew) const [analyzing, setAnalyzing] = useState(false) const [saving, setSaving] = useState(false) const [validating, setValidating] = useState(false) const [validationReport, setValidationReport] = useState(null) const [error, setError] = useState(null) /** Entwurf für „Quelle entspricht Ziel“ (nur source_unit custom); Commit bei Blur/Speichern. */ const [customEquivalenceDraftByField, setCustomEquivalenceDraftByField] = useState({}) /** Zeilenaggregation: null in DB = Modul-Standard; sonst import_row_processing */ const [rowAggUseCustom, setRowAggUseCustom] = useState(false) const [rowAggGroupBy, setRowAggGroupBy] = useState([]) const [rowAggMode, setRowAggMode] = useState('') const [rowAggIrregular, setRowAggIrregular] = useState(false) const [rowAggJsonText, setRowAggJsonText] = useState('{}') const [rowAggMultiRowPolicy, setRowAggMultiRowPolicy] = useState('aggregate') const [rowAggDedupeIdentical, setRowAggDedupeIdentical] = useState(false) const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module]) const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate' const targetOptions = useMemo(() => { if (!modMeta?.fields || aggregateSleepImport) return [] const entries = Object.entries(modMeta.fields).map(([key, meta]) => { const title = meta.label_de || meta.name_de || key return { value: key, label: `${title}${meta.required ? ' *' : ''}`, group: meta.from_training_parameter ? 'eav' : 'log', } }) entries.sort((a, b) => { if (a.group !== b.group) return a.group === 'log' ? -1 : 1 return a.label.localeCompare(b.label, 'de') }) return entries }, [modMeta, aggregateSleepImport]) const requiredTargets = useMemo(() => { if (!modMeta?.fields) return [] return Object.entries(modMeta.fields) .filter(([, v]) => v.required) .map(([k]) => k) }, [modMeta]) useEffect(() => { api .getCsvModules() .then((r) => setModules(r.modules || [])) .catch(() => {}) }, []) useEffect(() => { if (!module) return api .adminListCsvTemplates(module) .then((d) => setSeedOptions(d.templates || [])) .catch(() => setSeedOptions([])) }, [module]) useEffect(() => { if (!isNew) return setRowAggUseCustom(false) setRowAggIrregular(false) setRowAggGroupBy([]) setRowAggMode('') setRowAggJsonText('{}') setRowAggMultiRowPolicy('aggregate') setRowAggDedupeIdentical(false) }, [module, isNew]) useEffect(() => { if (isNew || !templateId) return let ok = true setLoading(true) setError(null) api .adminGetCsvTemplate(templateId) .then((t) => { if (!ok) return setModule(t.module) setMappingName(t.mapping_name || '') setDescription(t.description || '') setDelimiter(t.delimiter || ',') setEncoding(t.encoding || 'utf-8') setHasHeader(!!t.has_header) setColumnSignature(t.column_signature || []) const fm = t.field_mappings || {} setFieldMappings(fm) setColumns(Object.keys(fm)) setTypeConversionsText(JSON.stringify(t.type_conversions || {}, null, 2)) setCustomEquivalenceDraftByField({}) setSampleRows([]) setSeedHint(null) const rp = parseStoredImportRowProcessing(t.import_row_processing) setRowAggUseCustom(rp.useCustom) setRowAggIrregular(rp.irregular) setRowAggGroupBy(rp.groupBy) setRowAggMode(rp.mode) setRowAggMultiRowPolicy(rp.multiRowPolicy) setRowAggDedupeIdentical(rp.dedupeIdentical) setRowAggJsonText(JSON.stringify(t.import_row_processing || {}, null, 2)) }) .catch((e) => { if (ok) setError(e.message) }) .finally(() => { if (ok) setLoading(false) }) return () => { ok = false } }, [isNew, templateId]) const assignedTargets = useMemo(() => { return new Set( Object.values(fieldMappings).filter((v) => v && v !== '-' && v !== '_skip'), ) }, [fieldMappings]) const rowAggGroupCandidates = useMemo(() => { return [...assignedTargets].filter((t) => modMeta?.fields?.[t]).sort() }, [assignedTargets, modMeta]) useEffect(() => { const targets = new Set( Object.values(fieldMappings).filter((v) => v && v !== '-' && v !== '_skip'), ) setRowAggGroupBy((prev) => prev.filter((g) => targets.has(g))) }, [fieldMappings]) const missingRequired = useMemo(() => { return requiredTargets.filter((r) => !assignedTargets.has(r)) }, [requiredTargets, assignedTargets]) const unitTargets = useMemo(() => { if (!modMeta?.fields || aggregateSleepImport) return [] const list = [] const seen = new Set() for (const t of assignedTargets) { const opts = modMeta.fields[t]?.source_unit_options if (!opts?.length || seen.has(t)) continue seen.add(t) list.push({ field: t, options: opts }) } return list.sort((a, b) => a.field.localeCompare(b.field)) }, [modMeta, assignedTargets, aggregateSleepImport]) const getSourceUnitSelectValue = (fieldKey) => { let tc try { tc = JSON.parse(typeConversionsText || '{}') } catch { return null } const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || [] const canonical = opts.find((o) => o.is_canonical)?.id || opts[0]?.id const su = tc[fieldKey]?.source_unit if (su != null && su !== '') { const sid = String(su).toLowerCase() const hit = opts.find((o) => o.id === sid) if (hit) return hit.id } const ce = tc[fieldKey]?.custom_equivalence if (ce && typeof ce === 'object' && opts.some((o) => o.id === 'custom')) { return 'custom' } if (tc[fieldKey]?.conversion_factor != null && tc[fieldKey]?.conversion_factor !== '') { const hasCustomOpt = opts.some((o) => o.id === 'custom') if (hasCustomOpt) return 'custom' } return canonical || '' } const updateSourceUnit = (fieldKey, sourceUnitId) => { let tc try { tc = JSON.parse(typeConversionsText || '{}') } catch { setError('type_conversions: ungültiges JSON (Quelleinheit kann nicht gesetzt werden).') return } const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || [] const canonical = opts.find((o) => o.is_canonical)?.id const base = tc[fieldKey] && typeof tc[fieldKey] === 'object' ? { ...tc[fieldKey] } : { type: 'float', decimal_separator: 'auto', flexible: true } delete base.target_unit if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) { delete base.source_unit delete base.conversion_factor delete base.custom_equivalence } else if (sourceUnitId === 'custom') { base.source_unit = 'custom' } else { base.source_unit = sourceUnitId delete base.conversion_factor delete base.custom_equivalence } tc[fieldKey] = base setTypeConversionsText(JSON.stringify(tc, null, 2)) setCustomEquivalenceDraftByField((prev) => { const next = { ...prev } delete next[fieldKey] return next }) setError(null) } const getCanonicalStorageUnitLabel = (fieldKey) => { const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || [] const c = opts.find((o) => o.is_canonical) return c?.canonical_unit || c?.id || '—' } const getEquivalenceDisplay = (fieldKey) => { if (customEquivalenceDraftByField[fieldKey]) { return customEquivalenceDraftByField[fieldKey] } try { const tc = JSON.parse(typeConversionsText || '{}') return loadEquivalenceFromTc(tc, fieldKey) } catch { return { srcAmt: '1', srcUnit: '', tgtAmt: '' } } } const mergeEquivalenceDraft = (fieldKey, patch) => { setCustomEquivalenceDraftByField((prev) => { let baseTc try { baseTc = JSON.parse(typeConversionsText || '{}') } catch { baseTc = {} } const cur = prev[fieldKey] || loadEquivalenceFromTc(baseTc, fieldKey) return { ...prev, [fieldKey]: { ...cur, ...patch } } }) } const commitCustomEquivalenceOnBlur = (fieldKey) => { if (getSourceUnitSelectValue(fieldKey) !== 'custom') { setCustomEquivalenceDraftByField((prev) => { const next = { ...prev } delete next[fieldKey] return next }) return } let tc try { tc = JSON.parse(typeConversionsText || '{}') } catch { setError('type_conversions: ungültiges JSON (Umrechnung kann nicht gespeichert werden).') return } const draft = customEquivalenceDraftByField[fieldKey] || loadEquivalenceFromTc(tc, fieldKey) const canon = getCanonicalStorageUnitLabel(fieldKey) const result = applyCustomEquivalenceToTcObject(tc, fieldKey, draft, canon) if (result.partial) { setError(null) return } if (!result.ok) { setError(result.message || 'Ungültige Umrechnung.') return } setTypeConversionsText(JSON.stringify(tc, null, 2)) setCustomEquivalenceDraftByField((prev) => { const next = { ...prev } delete next[fieldKey] return next }) setError(null) } const derivedFactorHintLine = (fieldKey) => { const d = getEquivalenceDisplay(fieldKey) const sa = normalizeDecimalInputString(d.srcAmt ?? '') const ta = normalizeDecimalInputString(d.tgtAmt ?? '') if (!sa || !ta) return null const a = Number(sa) const b = Number(ta) if (Number.isNaN(a) || Number.isNaN(b) || a === 0) return null const f = b / a const fDisp = Number.isFinite(f) ? Math.round(f * 1e9) / 1e9 : f const canon = getCanonicalStorageUnitLabel(fieldKey) return `Abgeleitet: CSV-Zahl × ${fDisp} → Wert in ${canon} (Speicher)` } const handleAnalyze = async () => { if (!file) { setError('Bitte eine CSV-Datei wählen.') return } setAnalyzing(true) setError(null) try { const delim = delimiterOverride || null const seed = seedTemplateId === '' ? null : Number(seedTemplateId) const res = await api.adminAnalyzeCsvTemplate(file, module, delim, seed) setColumns(res.columns || []) setColumnSignature(res.column_signature_normalized || []) setFieldMappings(res.field_mappings || {}) setTypeConversionsText(JSON.stringify(res.type_conversions || {}, null, 2)) setSampleRows(res.sample_rows || []) setDelimiter(res.delimiter || ';') setEncoding(res.encoding || 'utf-8') setSeedHint(res.seed_template || null) setCustomEquivalenceDraftByField({}) setRowAggUseCustom(false) setRowAggIrregular(false) setRowAggGroupBy([]) setRowAggMode('') setRowAggJsonText('{}') setRowAggMultiRowPolicy('aggregate') setRowAggDedupeIdentical(false) } catch (e) { setError(e.message || 'Analyse fehlgeschlagen') } finally { setAnalyzing(false) } } const updateMapping = (col, dbField) => { setFieldMappings((prev) => ({ ...prev, [col]: dbField || '-' })) } const handleFormatCheck = async () => { setError(null) setValidationReport(null) let tc try { tc = JSON.parse(typeConversionsText || '{}') if (tc !== null && typeof tc !== 'object') throw new Error() } catch { setError('type_conversions: ungültiges JSON.') return } if (!module) { setError('Modul wählen.') return } setValidating(true) try { const r = await api.adminValidateCsvTemplate({ module, field_mappings: fieldMappings, type_conversions: tc, import_row_processing: null, column_signature: columnSignature.length ? columnSignature : null, }) setValidationReport(r) } catch (e) { setError(e.message || 'Formatprüfung fehlgeschlagen') } finally { setValidating(false) } } const handleSave = async () => { setError(null) let textForTc = typeConversionsText const pendingEquivalenceDrafts = { ...customEquivalenceDraftByField } if (Object.keys(pendingEquivalenceDrafts).length > 0) { try { const tco = JSON.parse(textForTc || '{}') for (const fk of Object.keys(pendingEquivalenceDrafts)) { const su = String(tco[fk]?.source_unit || '').toLowerCase() if (su !== 'custom') continue const draft = pendingEquivalenceDrafts[fk] const opts = modMeta?.fields?.[fk]?.source_unit_options || [] const c = opts.find((o) => o.is_canonical) const canon = c?.canonical_unit || c?.id || '—' const result = applyCustomEquivalenceToTcObject(tco, fk, draft, canon) if (result.partial) { setError( `Benutzerdefinierte Umrechnung (${fk}): beide Mengen ausfüllen oder Entwurf verwerfen (Tab durch alle Felder).`, ) return } if (!result.ok) { setError(result.message || 'Benutzerdefinierte Umrechnung ungültig.') return } } textForTc = JSON.stringify(tco, null, 2) setTypeConversionsText(textForTc) setCustomEquivalenceDraftByField({}) } catch { setError('type_conversions: ungültiges JSON.') return } } let tc = null try { tc = JSON.parse(textForTc || '{}') if (tc !== null && typeof tc !== 'object') throw new Error() } catch { setError('type_conversions: ungültiges JSON.') return } if (!mappingName.trim()) { setError('Bitte einen Namen für die Vorlage eingeben.') return } if (!columns.length || !Object.keys(fieldMappings).length) { setError('Keine Spalten-Zuordnung: CSV analysieren oder Vorlage laden.') return } if (missingRequired.length) { setError(`Pflicht-Zielfelder fehlen: ${missingRequired.join(', ')}`) return } let import_row_processing = null if (!aggregateSleepImport && rowAggUseCustom) { if (rowAggIrregular) { try { import_row_processing = JSON.parse(rowAggJsonText || '{}') if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad') } catch { setError('Zeilenaggregation: ungültiges JSON.') return } const gb = import_row_processing.group_by if (!Array.isArray(gb) || !gb.length) { setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.') return } } else { if (!rowAggGroupBy.length) { setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.') return } if (!rowAggMode) { setError('Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).') return } for (const g of rowAggGroupBy) { if (!assignedTargets.has(g)) { setError(`Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`) return } } import_row_processing = buildImportRowProcessingSimple( modMeta?.fields, fieldMappings, rowAggGroupBy, rowAggMode, rowAggMultiRowPolicy, rowAggDedupeIdentical, ) } } const payload = { module, mapping_name: mappingName.trim(), description: description.trim() || null, column_signature: columnSignature.length ? columnSignature : null, delimiter, encoding: encoding || 'utf-8', has_header: hasHeader, field_mappings: fieldMappings, type_conversions: tc, import_row_processing, } if (!payload.column_signature?.length) { setError('column_signature fehlt — bitte CSV erneut analysieren.') return } setSaving(true) try { if (isNew) { await api.adminCreateCsvTemplate(payload) } else { const { module: _m, ...patch } = payload await api.adminUpdateCsvTemplate(templateId, patch) } navigate('/admin/csv-templates') } catch (e) { setError(e.message || 'Speichern fehlgeschlagen') } finally { setSaving(false) } } const handleDelete = async () => { if (isNew || !templateId) return if (!confirm('System-Vorlage wirklich löschen?')) return setError(null) try { await api.adminDeleteCsvTemplate(templateId) navigate('/admin/csv-templates') } catch (e) { setError(e.message || 'Löschen fehlgeschlagen') } } if (loading) { return (
) } return (
Zur Liste

{isNew ? 'Neue CSV-Vorlage' : 'Vorlage bearbeiten'}

{error && (
{error}
)}
Modul
{!isNew && (

Modul bestehender Vorlagen kann nicht geändert werden. System-Vorlagen können hier bearbeitet und gespeichert werden (Signatur, Trennzeichen, Zuordnungen).

)} {aggregateSleepImport && (

Schlaf (Apple-Aggregat): Die Zeilen werden nicht über Spalten-Ziele importiert, sondern vom Apple-Schlaf-Parser ausgewertet (Schlafanalyse oder Segment-Export). Alle CSV-Spalten bleiben auf „ignorieren“. Wichtig sind die{' '} gespeicherte Spalten-Signatur und das passende Datei-Format — damit die Datei in der Nutzer-Auswahl erkannt wird.

)}
1. Beispiel-CSV (wie Import)
setFile(e.target.files?.[0] || null)} />
{seedHint && (

Seed: {seedHint.mapping_name} · Vorlage abgedeckt{' '} {Math.round((seedHint.confidence || 0) * 100)} % {seedHint.columns_matched != null && seedHint.columns_in_template != null ? ` (${seedHint.columns_matched}/${seedHint.columns_in_template} Spalten)` : ''} {seedHint.jaccard != null && ( <> {' '} · Jaccard {Math.round(seedHint.jaccard * 100)} % )}

)} {sampleRows.length > 0 && }
2. Stammdaten
setMappingName(e.target.value)} placeholder="z. B. FDDB Export 2026" />