mitai-jinkendo/frontend/src/pages/AdminCsvTemplateEditorPage.jsx
Lars 08eae86ddc
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: Refactor activity import logic and enhance CSV handling
- Replaced the deprecated `resolve_activity_log_column_patch_from_csv` function with `activity_csv_registry_updates_from_mapped` to streamline updates from CSV mappings.
- Updated the `_import_activity` function to utilize the new registry updates, improving data integrity during activity imports.
- Enhanced the activity module registry by adding German labels for various fields, improving localization support.
- Refactored the session metrics handling to ensure only relevant fields are processed, enhancing the overall robustness of CSV imports.
2026-04-15 10:35:48 +02:00

1556 lines
58 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div style={{ overflowX: 'auto', marginTop: 12 }}>
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<thead>
<tr>
{showCols.map((c) => (
<th
key={c}
style={{
textAlign: 'left',
padding: '8px 6px',
borderBottom: '1px solid var(--border)',
color: 'var(--text2)',
whiteSpace: 'nowrap',
}}
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{sampleRows.slice(0, 5).map((row, i) => (
<tr key={i}>
{showCols.map((c) => (
<td key={c} style={csvPreviewTdStyle(row[c] ?? '—')}>
{row[c] ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
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 (
<div style={{ padding: 24, textAlign: 'center' }}>
<Loader2 size={28} className="spin" style={{ animation: 'spin 0.8s linear infinite' }} />
</div>
)
}
return (
<div style={{ padding: '16px 16px 96px', maxWidth: 920, margin: '0 auto' }}>
<Link
to="/admin/csv-templates"
className="btn btn-secondary"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 16, textDecoration: 'none' }}
>
<ArrowLeft size={18} /> Zur Liste
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FileSpreadsheet size={26} strokeWidth={2} />
{isNew ? 'Neue CSV-Vorlage' : 'Vorlage bearbeiten'}
</h1>
{error && (
<div className="card" style={{ padding: 12, borderColor: 'var(--danger)', color: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
)}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">Modul</div>
<select
className="form-input"
value={module}
disabled={!isNew}
onChange={(e) => setModule(e.target.value)}
style={{ width: '100%', marginTop: 8, textAlign: 'left', minHeight: 46, padding: '11px 14px' }}
>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{MODULE_LABEL[m.id] || m.id}
</option>
))}
</select>
{!isNew && (
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8 }}>
Modul bestehender Vorlagen kann nicht geändert werden. System-Vorlagen können hier bearbeitet und
gespeichert werden (Signatur, Trennzeichen, Zuordnungen).
</p>
)}
{aggregateSleepImport && (
<p
style={{
fontSize: 13,
color: 'var(--text2)',
marginTop: 12,
lineHeight: 1.55,
padding: 12,
background: 'var(--surface2)',
borderRadius: 12,
}}
>
<strong>Schlaf (Apple-Aggregat):</strong> 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{' '}
<strong>gespeicherte Spalten-Signatur</strong> und das passende Datei-Format damit die Datei
in der Nutzer-Auswahl erkannt wird.
</p>
)}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">1. Beispiel-CSV (wie Import)</div>
<input
type="file"
accept=".csv,text/csv"
className="form-input"
style={{ marginTop: 8, width: '100%' }}
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<div style={{ marginTop: 12, display: 'grid', gap: 12 }}>
<label style={{ fontSize: 14, color: 'var(--text2)' }}>
Trennzeichen (optional, sonst automatisch):
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={delimiterOverride}
onChange={(e) => setDelimiterOverride(e.target.value)}
>
<option value="">Auto</option>
<option value=";">Semikolon</option>
<option value=",">Komma</option>
<option value="\t">Tab</option>
</select>
</label>
<label style={{ fontSize: 14, color: 'var(--text2)' }}>
Optional: feste Seed-Vorlage für Vorschläge:
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={seedTemplateId}
onChange={(e) => setSeedTemplateId(e.target.value)}
>
<option value="">Beste passende System-Vorlage (Abdeckung der Vorlagen-Spalten)</option>
{seedOptions.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.mapping_name}
</option>
))}
</select>
</label>
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 16, width: '100%' }}
disabled={!file || analyzing}
onClick={handleAnalyze}
>
{analyzing ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Analysiere
</>
) : (
'CSV analysieren & Vorschläge'
)}
</button>
{seedHint && (
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 12, lineHeight: 1.5 }}>
Seed: <strong>{seedHint.mapping_name}</strong> · Vorlage abgedeckt{' '}
<strong>{Math.round((seedHint.confidence || 0) * 100)} %</strong>
{seedHint.columns_matched != null && seedHint.columns_in_template != null
? ` (${seedHint.columns_matched}/${seedHint.columns_in_template} Spalten)`
: ''}
{seedHint.jaccard != null && (
<>
{' '}
· Jaccard <strong>{Math.round(seedHint.jaccard * 100)} %</strong>
</>
)}
</p>
)}
{sampleRows.length > 0 && <SampleTable sampleRows={sampleRows} columns={columns} />}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">2. Stammdaten</div>
<label className="form-label" style={{ marginTop: 12 }}>
Name *
</label>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
value={mappingName}
onChange={(e) => setMappingName(e.target.value)}
placeholder="z. B. FDDB Export 2026"
/>
<label className="form-label" style={{ marginTop: 12 }}>
Beschreibung
</label>
<textarea
className="form-input"
style={{ width: '100%', minHeight: 64, textAlign: 'left' }}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<label>
<span className="form-label">Trennzeichen (gespeichert)</span>
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={delimiter}
onChange={(e) => setDelimiter(e.target.value)}
>
<option value=";">Semikolon</option>
<option value=",">Komma</option>
<option value="\t">Tab</option>
</select>
</label>
<label>
<span className="form-label">Kopfzeile</span>
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={hasHeader ? 'yes' : 'no'}
onChange={(e) => setHasHeader(e.target.value === 'yes')}
>
<option value="yes">Ja</option>
<option value="no">Nein (nicht unterstützt im Import)</option>
</select>
</label>
</div>
<label className="form-label" style={{ marginTop: 16, display: 'block' }}>
Spalten-Signatur (Vorlagen-Matching eine Original-Überschrift pro Zeile, manuell anpassbar)
</label>
<textarea
className="form-input"
style={{
width: '100%',
minHeight: 100,
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'left',
}}
value={columnSignature.join('\n')}
onChange={(e) =>
setColumnSignature(
e.target.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder={'Date/Time\nStart\nEnd\nTotal Sleep (hr)\n…'}
/>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3. Spalten Zielfelder (* = Pflicht)</div>
{!columns.length ? (
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 8 }}>
Nach CSV-Analyse erscheinen die Zeilen hier. Bei Schlaf-Vorlagen ohne Analyse: Signatur oben pflegen,
speichern, oder Beispiel-CSV analysieren.
</p>
) : (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{columns.map((col) => (
<div
key={col}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, min(52vw, 440px))',
gap: '10px 16px',
alignItems: 'center',
}}
>
<code style={{ fontSize: 12, wordBreak: 'break-word', color: 'var(--text2)', textAlign: 'left' }}>
{col}
</code>
<select
className="form-input"
value={fieldMappings[col] || '-'}
onChange={(e) => updateMapping(col, e.target.value)}
disabled={aggregateSleepImport}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
<option value="-"> ignorieren</option>
{['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 (
<optgroup key={g} label={ogLabel}>
{opts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
)
})}
</select>
</div>
))}
</div>
)}
{missingRequired.length > 0 && (
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 12 }}>
Noch zuzuweisen: {missingRequired.join(', ')}
</p>
)}
</div>
{!aggregateSleepImport && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3a. Zeilenaggregation</div>
{!modMeta?.fields || Object.keys(modMeta.fields).length === 0 ? (
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 8 }}>
Modul-Metadaten laden bitte Seite kurz offen lassen oder neu laden.
</p>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}>
<strong>Schlüsselfelder</strong> bestimmen, wann CSV-Zeilen dieselbe Gruppe sind. Was bei{' '}
<strong>mehr als einer Zeile pro Gruppe</strong> passiert, steuern Sie unten (
<em>Zusammenführen / Abweisen / nur erste oder letzte Zeile</em>). Optional können{' '}
<strong>völlig identische</strong> gemappte Zeilen vorher entfernt werden.
</p>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
marginTop: 14,
cursor: 'pointer',
fontSize: 14,
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={rowAggUseCustom}
onChange={(e) => {
const on = e.target.checked
setRowAggUseCustom(on)
if (!on) {
setRowAggIrregular(false)
setRowAggGroupBy([])
setRowAggMode('')
setRowAggJsonText('{}')
setRowAggMultiRowPolicy('aggregate')
setRowAggDedupeIdentical(false)
}
}}
style={{ marginTop: 3 }}
/>
<span>
<strong>Eigene Zeilenlogik in dieser Vorlage speichern.</strong> Wenn deaktiviert, nutzt der Import den{' '}
<strong>Legacy-Fallback im Server-Code</strong> (nur solange die Vorlage kein JSON speichert
mittelfristig sollen alle Vorlagen explizit sein).
</span>
</label>
{modMeta.import_row_processing_default && (
<details style={{ marginTop: 12, fontSize: 13, color: 'var(--text2)' }}>
<summary style={{ cursor: 'pointer' }}>
Legacy-Fallback im Code (Referenz, wenn der Haken oben aus ist)
</summary>
<pre
style={{
marginTop: 8,
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
overflow: 'auto',
fontSize: 12,
textAlign: 'left',
}}
>
{JSON.stringify(modMeta.import_row_processing_default, null, 2)}
</pre>
</details>
)}
{rowAggUseCustom && (
<>
{modMeta.import_row_processing_default && (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12 }}
onClick={() => {
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
</button>
)}
{rowAggIrregular ? (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 14, lineHeight: 1.55 }}>
Diese Vorlage nutzt <strong>unterschiedliche</strong> Aggregations-Funktionen pro Feld. JSON
anpassen oder vereinheitlichen (pro-Feld-Auswahl später). Optional:{' '}
<code>multi_row_policy</code> (<code>aggregate</code> | <code>reject</code> |{' '}
<code>first_row</code> | <code>last_row</code>), <code>dedupe_identical_rows</code>: true.
</p>
</>
) : (
<>
<div className="form-label" style={{ marginTop: 16 }}>
Schlüsselfelder (Mehrfachauswahl)
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>
Nur bereits zugewiesene Zielfelder (Abschnitt 3).
</p>
{rowAggGroupCandidates.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Noch keine Zielfelder zugewiesen nach Zuweisung erscheinen die Schlüssel hier.
</p>
) : (
<div
style={{
marginTop: 10,
display: 'flex',
flexDirection: 'column',
gap: 8,
alignItems: 'flex-start',
}}
>
{rowAggGroupCandidates.map((key) => (
<label
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
fontSize: 14,
}}
>
<input
type="checkbox"
checked={rowAggGroupBy.includes(key)}
onChange={() => {
setRowAggIrregular(false)
setRowAggGroupBy((prev) =>
prev.includes(key) ? prev.filter((x) => x !== key) : [...prev, key],
)
}}
/>
<code>{key}</code>
</label>
))}
</div>
)}
<label className="form-label" style={{ marginTop: 16, display: 'block' }}>
Funktion für alle übrigen Zielfelder
</label>
<select
className="form-input"
style={{
width: '100%',
maxWidth: 420,
marginTop: 8,
textAlign: 'left',
minHeight: 46,
}}
value={rowAggMode}
onChange={(e) => {
setRowAggIrregular(false)
setRowAggMode(e.target.value)
}}
>
<option value=""> wählen </option>
{ROW_AGG_OPS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<label className="form-label" style={{ marginTop: 16, display: 'block' }}>
Mehrere Zeilen pro Schlüssel
</label>
<select
className="form-input"
style={{
width: '100%',
maxWidth: 520,
marginTop: 8,
textAlign: 'left',
minHeight: 46,
}}
value={rowAggMultiRowPolicy}
onChange={(e) => {
setRowAggIrregular(false)
setRowAggMultiRowPolicy(e.target.value)
}}
>
{MULTI_ROW_POLICY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
marginTop: 14,
cursor: 'pointer',
fontSize: 14,
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={rowAggDedupeIdentical}
onChange={(e) => {
setRowAggIrregular(false)
setRowAggDedupeIdentical(e.target.checked)
}}
style={{ marginTop: 3 }}
/>
<span>
<strong>Identische Zeilen vorher entfernen</strong> (alle gemappten Felder gleich nur die erste
Zeile jeder Kopie bleibt).
</span>
</label>
</>
)}
<div className="form-label" style={{ marginTop: 18 }}>
import_row_processing (JSON)
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 6, lineHeight: 1.55 }}>
{rowAggIrregular ? (
<>
<strong>Bearbeitbar</strong> wie bei <code>type_conversions</code>; beim Speichern prüft das
Backend die Struktur.
</>
) : (
<>
<strong>Nur Lesen</strong> Vorschau aus den Feldern oben; dasselbe JSON wird beim Speichern
geschrieben.
</>
)}
</p>
<textarea
className="form-input"
readOnly={!rowAggIrregular}
style={{
width: '100%',
minHeight: rowAggIrregular ? 200 : 160,
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'left',
opacity: rowAggIrregular ? 1 : 0.95,
background: rowAggIrregular ? undefined : 'var(--surface2)',
}}
value={
rowAggIrregular
? rowAggJsonText
: rowAggGroupBy.length && rowAggMode
? JSON.stringify(
buildImportRowProcessingSimple(
modMeta?.fields,
fieldMappings,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
),
null,
2,
)
: JSON.stringify(
{
_hinweis:
'Mindestens ein Schlüsselfeld und eine gemeinsame Funktion wählen — dann erscheint hier das finale JSON.',
},
null,
2,
)
}
onChange={rowAggIrregular ? (e) => setRowAggJsonText(e.target.value) : undefined}
spellCheck={false}
/>
</>
)}
</>
)}
</div>
)}
{unitTargets.length > 0 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3b. Quelleinheit (optional)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}>
Ziel-Einheit kommt aus dem Datenmodell. Standard-Umrechnungen im Dropdown; bei &quot;Benutzerdefiniert&quot;
die Bezugsgröße eintragen: <strong>Menge [Quelleinheit] entspricht Menge [Zieleinheit]</strong> (Ziel ist die
Speicher-Einheit des Feldes). Im JSON siehst du weiterhin <code>conversion_factor</code> und{' '}
<code>custom_equivalence</code> zur Dokumentation.
</p>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 16 }}>
{unitTargets.map(({ field: fkey, options }) => (
<div key={fkey}>
<label style={{ display: 'block' }}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
{fkey}
</span>
<select
className="form-input"
value={getSourceUnitSelectValue(fkey) || ''}
onChange={(e) => updateSourceUnit(fkey, e.target.value)}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</label>
{getSourceUnitSelectValue(fkey) === 'custom' && (
<div style={{ marginTop: 12 }}>
<span className="form-label" style={{ display: 'block', marginBottom: 8 }}>
Bezugsgröße (CSV steht in der linken Einheit)
</span>
<datalist id={`csv-custom-units-${fkey}`}>
{CUSTOM_SOURCE_UNIT_HINTS.map((h) => (
<option key={h} value={h} />
))}
</datalist>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 10,
rowGap: 12,
}}
>
<input
type="text"
inputMode="decimal"
className="form-input"
placeholder="Menge"
aria-label="Menge in Quelleinheit"
value={getEquivalenceDisplay(fkey).srcAmt}
onChange={(e) => mergeEquivalenceDraft(fkey, { srcAmt: e.target.value })}
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
style={{ width: 96, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
/>
<input
type="text"
className="form-input"
placeholder="Quelleinheit"
list={`csv-custom-units-${fkey}`}
aria-label="Quelleinheit (z. B. ml)"
value={getEquivalenceDisplay(fkey).srcUnit}
onChange={(e) => mergeEquivalenceDraft(fkey, { srcUnit: e.target.value })}
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
style={{ width: 120, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
/>
<span style={{ fontSize: 14, color: 'var(--text2)' }}>entspricht</span>
<input
type="text"
inputMode="decimal"
className="form-input"
placeholder="Menge"
aria-label="Menge in Speicher-Einheit"
value={getEquivalenceDisplay(fkey).tgtAmt}
onChange={(e) => mergeEquivalenceDraft(fkey, { tgtAmt: e.target.value })}
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
style={{ width: 96, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
/>
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text1)' }}>
{getCanonicalStorageUnitLabel(fkey)}
</span>
<span style={{ fontSize: 13, color: 'var(--text3)' }}>(Ziel / Speicher)</span>
</div>
{derivedFactorHintLine(fkey) ? (
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 10 }}>
{derivedFactorHintLine(fkey)}
</p>
) : null}
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, lineHeight: 1.5 }}>
Beispiel: <strong>1</strong> <strong>ml</strong> entspricht <strong>1,03</strong>{' '}
<strong>{getCanonicalStorageUnitLabel(fkey)}</strong>, wenn die CSV Milliliter liefert und die
Dichte etwa 1,03&nbsp;g/ml ist. Alle drei Felder leer lassen = keine Zusatz-Umrechnung. Tab /
Fokus weg oder Speichern übernimmt.
</p>
</div>
)}
</div>
))}
</div>
</div>
)}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">4. type_conversions (JSON)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}>
Vom Vorschlag übernommen; bei Dropdowns 3b werden <code>source_unit</code>, ggf.{' '}
<code>conversion_factor</code> und <code>custom_equivalence</code> gesetzt. Zusätzlich manuell z.B.
Datumsformat.
</p>
<textarea
className="form-input"
style={{
width: '100%',
minHeight: 200,
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'left',
}}
value={typeConversionsText}
onChange={(e) => {
setTypeConversionsText(e.target.value)
setCustomEquivalenceDraftByField({})
}}
/>
</div>
{validationReport ? (
<div className="card" style={{ padding: 16, marginBottom: 16, borderColor: validationReport.valid ? 'var(--accent)' : 'var(--danger)' }}>
<div className="form-label" style={{ marginBottom: 8 }}>
Formatprüfung (Vorlage){' '}
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}> speicherfähig</span> : <span style={{ color: 'var(--danger)' }}> Fehler beheben</span>}
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht.
</p>
{validationReport.errors?.length ? (
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
{validationReport.errors.map((e, i) => (
<li key={`e-${i}`}>
{e.message}
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
</li>
))}
</ul>
) : null}
{validationReport.warnings?.length ? (
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
{validationReport.warnings.map((w, i) => (
<li key={`w-${i}`}>
{w.message}
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
</li>
))}
</ul>
) : null}
</div>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<button type="button" className="btn btn-secondary" disabled={validating || saving} onClick={handleFormatCheck} style={{ minWidth: 160 }}>
{validating ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Prüfen
</>
) : (
'Format prüfen'
)}
</button>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave} style={{ flex: 1, minWidth: 160 }}>
{saving ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Speichern
</>
) : (
<>
<Save size={18} style={{ marginRight: 8 }} />
Speichern
</>
)}
</button>
{!isNew && (
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}>
<Trash2 size={18} style={{ marginRight: 8 }} />
Löschen
</button>
)}
</div>
</div>
)
}