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) => (
{c}
))}
{sampleRows.slice(0, 5).map((row, i) => (
{showCols.map((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
setModule(e.target.value)}
style={{ width: '100%', marginTop: 8, textAlign: 'left', minHeight: 46, padding: '11px 14px' }}
>
{modules.map((m) => (
{MODULE_LABEL[m.id] || m.id}
))}
{!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)}
/>
Trennzeichen (optional, sonst automatisch):
setDelimiterOverride(e.target.value)}
>
Auto
Semikolon
Komma
Tab
Optional: feste Seed-Vorlage für Vorschläge:
setSeedTemplateId(e.target.value)}
>
Beste passende System-Vorlage (Abdeckung der Vorlagen-Spalten)
{seedOptions.map((s) => (
{s.mapping_name}
))}
{analyzing ? (
<>
Analysiere …
>
) : (
'CSV analysieren & Vorschläge'
)}
{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 &&
}
3. Spalten → Zielfelder (* = Pflicht)
{!columns.length ? (
Nach CSV-Analyse erscheinen die Zeilen hier. Bei Schlaf-Vorlagen ohne Analyse: Signatur oben pflegen,
speichern, oder Beispiel-CSV analysieren.
) : (
{columns.map((col) => (
{col}
updateMapping(col, e.target.value)}
disabled={aggregateSleepImport}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
— ignorieren
{['log', 'eav'].map((g) => {
const opts = targetOptions.filter((o) => o.group === g)
if (!opts.length) return null
const ogLabel =
g === 'log'
? 'Activity — Kernfelder (activity_log)'
: 'Trainingsparameter (EAV)'
return (
{opts.map((o) => (
{o.label}
))}
)
})}
))}
)}
{missingRequired.length > 0 && (
Noch zuzuweisen: {missingRequired.join(', ')}
)}
{!aggregateSleepImport && (
3a. Zeilenaggregation
{!modMeta?.fields || Object.keys(modMeta.fields).length === 0 ? (
Modul-Metadaten laden … bitte Seite kurz offen lassen oder neu laden.
) : (
<>
Schlüsselfelder bestimmen, wann CSV-Zeilen dieselbe „Gruppe“ sind. Was bei{' '}
mehr als einer Zeile pro Gruppe passiert, steuern Sie unten (
Zusammenführen / Abweisen / nur erste oder letzte Zeile ). Optional können{' '}
völlig identische gemappte Zeilen vorher entfernt werden.
{
const on = e.target.checked
setRowAggUseCustom(on)
if (!on) {
setRowAggIrregular(false)
setRowAggGroupBy([])
setRowAggMode('')
setRowAggJsonText('{}')
setRowAggMultiRowPolicy('aggregate')
setRowAggDedupeIdentical(false)
}
}}
style={{ marginTop: 3 }}
/>
Eigene Zeilenlogik in dieser Vorlage speichern. Wenn deaktiviert, nutzt der Import den{' '}
Legacy-Fallback im Server-Code (nur solange die Vorlage kein JSON speichert —
mittelfristig sollen alle Vorlagen explizit sein).
{modMeta.import_row_processing_default && (
Legacy-Fallback im Code (Referenz, wenn der Haken oben aus ist)
{JSON.stringify(modMeta.import_row_processing_default, null, 2)}
)}
{rowAggUseCustom && (
<>
{modMeta.import_row_processing_default && (
{
const d = modMeta.import_row_processing_default
const p = parseStoredImportRowProcessing(d)
setRowAggIrregular(p.irregular)
setRowAggGroupBy(p.groupBy)
setRowAggMode(p.mode)
setRowAggMultiRowPolicy(p.multiRowPolicy)
setRowAggDedupeIdentical(p.dedupeIdentical)
setRowAggJsonText(JSON.stringify(d, null, 2))
}}
>
Modul-Vorgabe übernehmen
)}
{rowAggIrregular ? (
<>
Diese Vorlage nutzt unterschiedliche Aggregations-Funktionen pro Feld. JSON
anpassen oder vereinheitlichen (pro-Feld-Auswahl später). Optional:{' '}
multi_row_policy (aggregate | reject |{' '}
first_row | last_row), dedupe_identical_rows: true.
>
) : (
<>
Schlüsselfelder (Mehrfachauswahl)
Nur bereits zugewiesene Zielfelder (Abschnitt 3).
{rowAggGroupCandidates.length === 0 ? (
Noch keine Zielfelder zugewiesen — nach Zuweisung erscheinen die Schlüssel hier.
) : (
{rowAggGroupCandidates.map((key) => (
{
setRowAggIrregular(false)
setRowAggGroupBy((prev) =>
prev.includes(key) ? prev.filter((x) => x !== key) : [...prev, key],
)
}}
/>
{key}
))}
)}
Funktion für alle übrigen Zielfelder
{
setRowAggIrregular(false)
setRowAggMode(e.target.value)
}}
>
— wählen —
{ROW_AGG_OPS.map((o) => (
{o.label}
))}
Mehrere Zeilen pro Schlüssel
{
setRowAggIrregular(false)
setRowAggMultiRowPolicy(e.target.value)
}}
>
{MULTI_ROW_POLICY_OPTIONS.map((o) => (
{o.label}
))}
{
setRowAggIrregular(false)
setRowAggDedupeIdentical(e.target.checked)
}}
style={{ marginTop: 3 }}
/>
Identische Zeilen vorher entfernen (alle gemappten Felder gleich — nur die erste
Zeile jeder Kopie bleibt).
>
)}
import_row_processing (JSON)
{rowAggIrregular ? (
<>
Bearbeitbar — wie bei type_conversions; beim Speichern prüft das
Backend die Struktur.
>
) : (
<>
Nur Lesen — Vorschau aus den Feldern oben; dasselbe JSON wird beim Speichern
geschrieben.
>
)}
setRowAggJsonText(e.target.value) : undefined}
spellCheck={false}
/>
>
)}
>
)}
)}
{unitTargets.length > 0 && (
3b. Quelleinheit (optional)
Ziel-Einheit kommt aus dem Datenmodell. Standard-Umrechnungen im Dropdown; bei "Benutzerdefiniert"
die Bezugsgröße eintragen: Menge [Quelleinheit] entspricht Menge [Zieleinheit] (Ziel ist die
Speicher-Einheit des Feldes). Im JSON siehst du weiterhin conversion_factor und{' '}
custom_equivalence zur Dokumentation.
{unitTargets.map(({ field: fkey, options }) => (
{fkey}
updateSourceUnit(fkey, e.target.value)}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
{options.map((o) => (
{o.label}
))}
{getSourceUnitSelectValue(fkey) === 'custom' && (
Bezugsgröße (CSV steht in der linken Einheit)
{CUSTOM_SOURCE_UNIT_HINTS.map((h) => (
))}
mergeEquivalenceDraft(fkey, { srcAmt: e.target.value })}
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
style={{ width: 96, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
/>
mergeEquivalenceDraft(fkey, { srcUnit: e.target.value })}
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
style={{ width: 120, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
/>
entspricht
mergeEquivalenceDraft(fkey, { tgtAmt: e.target.value })}
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
style={{ width: 96, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
/>
{getCanonicalStorageUnitLabel(fkey)}
(Ziel / Speicher)
{derivedFactorHintLine(fkey) ? (
{derivedFactorHintLine(fkey)}
) : null}
Beispiel: 1 ml entspricht 1,03 {' '}
{getCanonicalStorageUnitLabel(fkey)} , wenn die CSV Milliliter liefert und die
Dichte etwa 1,03 g/ml ist. Alle drei Felder leer lassen = keine Zusatz-Umrechnung. Tab /
Fokus weg oder Speichern übernimmt.
)}
))}
)}
4. type_conversions (JSON)
Vom Vorschlag übernommen; bei Dropdowns 3b werden source_unit, ggf.{' '}
conversion_factor und custom_equivalence gesetzt. Zusätzlich manuell z. B.
Datumsformat.
{
setTypeConversionsText(e.target.value)
setCustomEquivalenceDraftByField({})
}}
/>
{validationReport ? (
Formatprüfung (Vorlage){' '}
{validationReport.valid ? — speicherfähig : — Fehler beheben }
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht.
{validationReport.errors?.length ? (
{validationReport.errors.map((e, i) => (
{e.message}
{e.hint ? {e.hint} : null}
))}
) : null}
{validationReport.warnings?.length ? (
{validationReport.warnings.map((w, i) => (
{w.message}
{w.hint ? {w.hint} : null}
))}
) : null}
) : null}
{validating ? (
<>
Prüfen …
>
) : (
'Format prüfen'
)}
{saving ? (
<>
Speichern …
>
) : (
<>
Speichern
>
)}
{!isNew && (
Löschen
)}
)
}