2026-04-10 06:39:41 +02:00
|
|
|
|
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'
|
2026-04-10 07:08:21 +02:00
|
|
|
|
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
2026-04-10 06:39:41 +02:00
|
|
|
|
|
|
|
|
|
|
const MODULE_LABEL = {
|
|
|
|
|
|
nutrition: 'Ernährung',
|
|
|
|
|
|
weight: 'Gewicht',
|
|
|
|
|
|
blood_pressure: 'Blutdruck',
|
|
|
|
|
|
activity: 'Aktivität',
|
2026-04-10 07:30:48 +02:00
|
|
|
|
sleep: 'Schlaf',
|
|
|
|
|
|
vitals_baseline: 'Vitalwerte (Baseline)',
|
2026-04-10 06:39:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:38:54 +02:00
|
|
|
|
/** 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',
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-10 15:22:31 +02:00
|
|
|
|
/** 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'])
|
|
|
|
|
|
|
|
|
|
|
|
function parseStoredImportRowProcessing(irp) {
|
|
|
|
|
|
if (!irp || typeof irp !== 'object' || Object.keys(irp).length === 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
useCustom: false,
|
|
|
|
|
|
irregular: false,
|
|
|
|
|
|
groupBy: [],
|
|
|
|
|
|
mode: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const gb = irp.group_by
|
|
|
|
|
|
const agg = irp.aggregates
|
|
|
|
|
|
if (!Array.isArray(gb) || gb.length === 0 || agg == null || typeof agg !== 'object') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
useCustom: true,
|
|
|
|
|
|
irregular: true,
|
|
|
|
|
|
groupBy: Array.isArray(gb) ? [...gb] : [],
|
|
|
|
|
|
mode: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const ops = [...new Set(Object.values(agg).map((x) => String(x)))]
|
|
|
|
|
|
if (ops.length !== 1) {
|
|
|
|
|
|
return { useCustom: true, irregular: true, groupBy: [...gb], mode: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
useCustom: true,
|
|
|
|
|
|
irregular: false,
|
|
|
|
|
|
groupBy: [...gb],
|
|
|
|
|
|
mode: ops[0],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildImportRowProcessingSimple(modFields, fm, groupBy, mode) {
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { group_by: groupBy, aggregates }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:25:38 +02:00
|
|
|
|
/** 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:38:54 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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) {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
const base =
|
|
|
|
|
|
tc[fieldKey] && typeof tc[fieldKey] === 'object'
|
|
|
|
|
|
? { ...tc[fieldKey] }
|
|
|
|
|
|
: { type: 'float', decimal_separator: 'auto', flexible: true }
|
2026-04-10 11:38:54 +02:00
|
|
|
|
const sa = normalizeDecimalInputString(draft.srcAmt ?? '')
|
|
|
|
|
|
const ta = normalizeDecimalInputString(draft.tgtAmt ?? '')
|
|
|
|
|
|
const unitLbl = String(draft.srcUnit ?? '').trim()
|
|
|
|
|
|
const bothEmpty = !sa && !ta
|
|
|
|
|
|
|
|
|
|
|
|
if (bothEmpty) {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
delete base.conversion_factor
|
2026-04-10 11:38:54 +02:00
|
|
|
|
delete base.custom_equivalence
|
2026-04-10 11:25:38 +02:00
|
|
|
|
base.source_unit = 'custom'
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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)',
|
2026-04-10 11:25:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
delete base.target_unit
|
|
|
|
|
|
tc[fieldKey] = base
|
|
|
|
|
|
return { ok: true }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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) => (
|
2026-04-10 07:08:21 +02:00
|
|
|
|
<td key={c} style={csvPreviewTdStyle(row[c] ?? '—')}>
|
2026-04-10 06:39:41 +02:00
|
|
|
|
{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 [error, setError] = useState(null)
|
2026-04-10 11:38:54 +02:00
|
|
|
|
/** Entwurf für „Quelle entspricht Ziel“ (nur source_unit custom); Commit bei Blur/Speichern. */
|
|
|
|
|
|
const [customEquivalenceDraftByField, setCustomEquivalenceDraftByField] = useState({})
|
2026-04-10 06:39:41 +02:00
|
|
|
|
|
2026-04-10 15:22:31 +02:00
|
|
|
|
/** 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('{}')
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module])
|
2026-04-10 07:52:04 +02:00
|
|
|
|
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
|
2026-04-10 06:39:41 +02:00
|
|
|
|
const targetOptions = useMemo(() => {
|
2026-04-10 07:52:04 +02:00
|
|
|
|
if (!modMeta?.fields || aggregateSleepImport) return []
|
2026-04-10 06:39:41 +02:00
|
|
|
|
return Object.entries(modMeta.fields).map(([key, meta]) => ({
|
|
|
|
|
|
value: key,
|
|
|
|
|
|
label: `${key}${meta.required ? ' *' : ''}`,
|
|
|
|
|
|
}))
|
2026-04-10 07:52:04 +02:00
|
|
|
|
}, [modMeta, aggregateSleepImport])
|
2026-04-10 06:39:41 +02:00
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-10 15:22:31 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isNew) return
|
|
|
|
|
|
setRowAggUseCustom(false)
|
|
|
|
|
|
setRowAggIrregular(false)
|
|
|
|
|
|
setRowAggGroupBy([])
|
|
|
|
|
|
setRowAggMode('')
|
|
|
|
|
|
setRowAggJsonText('{}')
|
|
|
|
|
|
}, [module, isNew])
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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))
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField({})
|
2026-04-10 06:39:41 +02:00
|
|
|
|
setSampleRows([])
|
|
|
|
|
|
setSeedHint(null)
|
2026-04-10 15:22:31 +02:00
|
|
|
|
const rp = parseStoredImportRowProcessing(t.import_row_processing)
|
|
|
|
|
|
setRowAggUseCustom(rp.useCustom)
|
|
|
|
|
|
setRowAggIrregular(rp.irregular)
|
|
|
|
|
|
setRowAggGroupBy(rp.groupBy)
|
|
|
|
|
|
setRowAggMode(rp.mode)
|
|
|
|
|
|
setRowAggJsonText(JSON.stringify(t.import_row_processing || {}, null, 2))
|
2026-04-10 06:39:41 +02:00
|
|
|
|
})
|
|
|
|
|
|
.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])
|
|
|
|
|
|
|
2026-04-10 15:22:31 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
const missingRequired = useMemo(() => {
|
|
|
|
|
|
return requiredTargets.filter((r) => !assignedTargets.has(r))
|
|
|
|
|
|
}, [requiredTargets, assignedTargets])
|
|
|
|
|
|
|
2026-04-10 09:54:32 +02:00
|
|
|
|
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
|
2026-04-10 10:42:59 +02:00
|
|
|
|
if (su != null && su !== '') {
|
|
|
|
|
|
const sid = String(su).toLowerCase()
|
|
|
|
|
|
const hit = opts.find((o) => o.id === sid)
|
|
|
|
|
|
if (hit) return hit.id
|
|
|
|
|
|
}
|
2026-04-10 11:38:54 +02:00
|
|
|
|
const ce = tc[fieldKey]?.custom_equivalence
|
|
|
|
|
|
if (ce && typeof ce === 'object' && opts.some((o) => o.id === 'custom')) {
|
|
|
|
|
|
return 'custom'
|
|
|
|
|
|
}
|
2026-04-10 11:19:44 +02:00
|
|
|
|
if (tc[fieldKey]?.conversion_factor != null && tc[fieldKey]?.conversion_factor !== '') {
|
|
|
|
|
|
const hasCustomOpt = opts.some((o) => o.id === 'custom')
|
|
|
|
|
|
if (hasCustomOpt) return 'custom'
|
|
|
|
|
|
}
|
2026-04-10 09:54:32 +02:00
|
|
|
|
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 }
|
2026-04-10 11:19:44 +02:00
|
|
|
|
delete base.target_unit
|
2026-04-10 09:54:32 +02:00
|
|
|
|
if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) {
|
|
|
|
|
|
delete base.source_unit
|
2026-04-10 11:19:44 +02:00
|
|
|
|
delete base.conversion_factor
|
2026-04-10 11:38:54 +02:00
|
|
|
|
delete base.custom_equivalence
|
2026-04-10 11:19:44 +02:00
|
|
|
|
} else if (sourceUnitId === 'custom') {
|
|
|
|
|
|
base.source_unit = 'custom'
|
2026-04-10 09:54:32 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
base.source_unit = sourceUnitId
|
2026-04-10 11:19:44 +02:00
|
|
|
|
delete base.conversion_factor
|
2026-04-10 11:38:54 +02:00
|
|
|
|
delete base.custom_equivalence
|
2026-04-10 11:19:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
tc[fieldKey] = base
|
|
|
|
|
|
setTypeConversionsText(JSON.stringify(tc, null, 2))
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField((prev) => {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
const next = { ...prev }
|
|
|
|
|
|
delete next[fieldKey]
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
2026-04-10 11:19:44 +02:00
|
|
|
|
setError(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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]
|
|
|
|
|
|
}
|
2026-04-10 11:19:44 +02:00
|
|
|
|
try {
|
2026-04-10 11:38:54 +02:00
|
|
|
|
const tc = JSON.parse(typeConversionsText || '{}')
|
|
|
|
|
|
return loadEquivalenceFromTc(tc, fieldKey)
|
2026-04-10 11:19:44 +02:00
|
|
|
|
} catch {
|
2026-04-10 11:38:54 +02:00
|
|
|
|
return { srcAmt: '1', srcUnit: '', tgtAmt: '' }
|
2026-04-10 11:19:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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 } }
|
|
|
|
|
|
})
|
2026-04-10 11:25:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:38:54 +02:00
|
|
|
|
const commitCustomEquivalenceOnBlur = (fieldKey) => {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
if (getSourceUnitSelectValue(fieldKey) !== 'custom') {
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField((prev) => {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
const next = { ...prev }
|
|
|
|
|
|
delete next[fieldKey]
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-10 11:19:44 +02:00
|
|
|
|
let tc
|
|
|
|
|
|
try {
|
|
|
|
|
|
tc = JSON.parse(typeConversionsText || '{}')
|
|
|
|
|
|
} catch {
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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)
|
2026-04-10 11:19:44 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-10 11:25:38 +02:00
|
|
|
|
if (!result.ok) {
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setError(result.message || 'Ungültige Umrechnung.')
|
2026-04-10 11:25:38 +02:00
|
|
|
|
return
|
2026-04-10 09:54:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
setTypeConversionsText(JSON.stringify(tc, null, 2))
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField((prev) => {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
const next = { ...prev }
|
|
|
|
|
|
delete next[fieldKey]
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
2026-04-10 09:54:32 +02:00
|
|
|
|
setError(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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)`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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)
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField({})
|
2026-04-10 15:22:31 +02:00
|
|
|
|
setRowAggUseCustom(false)
|
|
|
|
|
|
setRowAggIrregular(false)
|
|
|
|
|
|
setRowAggGroupBy([])
|
|
|
|
|
|
setRowAggMode('')
|
|
|
|
|
|
setRowAggJsonText('{}')
|
2026-04-10 06:39:41 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || 'Analyse fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setAnalyzing(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateMapping = (col, dbField) => {
|
|
|
|
|
|
setFieldMappings((prev) => ({ ...prev, [col]: dbField || '-' }))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
setError(null)
|
2026-04-10 11:25:38 +02:00
|
|
|
|
let textForTc = typeConversionsText
|
2026-04-10 11:38:54 +02:00
|
|
|
|
const pendingEquivalenceDrafts = { ...customEquivalenceDraftByField }
|
|
|
|
|
|
if (Object.keys(pendingEquivalenceDrafts).length > 0) {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
try {
|
|
|
|
|
|
const tco = JSON.parse(textForTc || '{}')
|
2026-04-10 11:38:54 +02:00
|
|
|
|
for (const fk of Object.keys(pendingEquivalenceDrafts)) {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
const su = String(tco[fk]?.source_unit || '').toLowerCase()
|
|
|
|
|
|
if (su !== 'custom') continue
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-10 11:25:38 +02:00
|
|
|
|
if (!result.ok) {
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setError(result.message || 'Benutzerdefinierte Umrechnung ungültig.')
|
2026-04-10 11:25:38 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
textForTc = JSON.stringify(tco, null, 2)
|
|
|
|
|
|
setTypeConversionsText(textForTc)
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField({})
|
2026-04-10 11:25:38 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
setError('type_conversions: ungültiges JSON.')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
let tc = null
|
|
|
|
|
|
try {
|
2026-04-10 11:25:38 +02:00
|
|
|
|
tc = JSON.parse(textForTc || '{}')
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 15:22:31 +02:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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,
|
2026-04-10 15:22:31 +02:00
|
|
|
|
import_row_processing,
|
2026-04-10 06:39:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)}
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{ width: '100%', marginTop: 8, textAlign: 'left', minHeight: 46, padding: '11px 14px' }}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
>
|
|
|
|
|
|
{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 }}>
|
2026-04-10 07:52:04 +02:00
|
|
|
|
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.
|
2026-04-10 06:39:41 +02:00
|
|
|
|
</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"
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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"
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
value={seedTemplateId}
|
|
|
|
|
|
onChange={(e) => setSeedTemplateId(e.target.value)}
|
|
|
|
|
|
>
|
2026-04-10 07:08:21 +02:00
|
|
|
|
<option value="">Beste passende System-Vorlage (Abdeckung der Vorlagen-Spalten)</option>
|
2026-04-10 06:39:41 +02:00
|
|
|
|
{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 && (
|
2026-04-10 07:08:21 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
</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"
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{ width: '100%', textAlign: 'left' }}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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"
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{ width: '100%', minHeight: 64, textAlign: 'left' }}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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>
|
2026-04-10 07:08:21 +02:00
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
|
|
|
|
|
|
value={delimiter}
|
|
|
|
|
|
onChange={(e) => setDelimiter(e.target.value)}
|
|
|
|
|
|
>
|
2026-04-10 06:39:41 +02:00
|
|
|
|
<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"
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
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>
|
2026-04-10 07:52:04 +02:00
|
|
|
|
<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…'}
|
|
|
|
|
|
/>
|
2026-04-10 06:39:41 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
|
|
|
|
<div className="form-label">3. Spalten → Zielfelder (* = Pflicht)</div>
|
|
|
|
|
|
{!columns.length ? (
|
2026-04-10 07:52:04 +02:00
|
|
|
|
<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>
|
2026-04-10 06:39:41 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
|
|
|
|
{columns.map((col) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={col}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'grid',
|
2026-04-10 07:08:21 +02:00
|
|
|
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, min(52vw, 440px))',
|
|
|
|
|
|
gap: '10px 16px',
|
2026-04-10 06:39:41 +02:00
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-10 07:08:21 +02:00
|
|
|
|
<code style={{ fontSize: 12, wordBreak: 'break-word', color: 'var(--text2)', textAlign: 'left' }}>
|
|
|
|
|
|
{col}
|
|
|
|
|
|
</code>
|
2026-04-10 06:39:41 +02:00
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={fieldMappings[col] || '-'}
|
|
|
|
|
|
onChange={(e) => updateMapping(col, e.target.value)}
|
2026-04-10 07:52:04 +02:00
|
|
|
|
disabled={aggregateSleepImport}
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
minHeight: 46,
|
|
|
|
|
|
textAlign: 'left',
|
|
|
|
|
|
padding: '11px 14px',
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
}}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
>
|
|
|
|
|
|
<option value="-">— ignorieren</option>
|
|
|
|
|
|
{targetOptions.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{missingRequired.length > 0 && (
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 12 }}>
|
|
|
|
|
|
Noch zuzuweisen: {missingRequired.join(', ')}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-10 15:26:59 +02:00
|
|
|
|
{!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 }}>
|
|
|
|
|
|
Mehrere CSV-Zeilen mit denselben Werten in den gewählten <strong>Schlüsselfeldern</strong> werden zu einer
|
|
|
|
|
|
importierten Zeile zusammengefasst. Für alle übrigen zugewiesenen Zielfelder gilt{' '}
|
|
|
|
|
|
<strong>eine gemeinsame</strong> Funktion. Textfelder werden bei Summe/Mittelwert usw. automatisch
|
|
|
|
|
|
ausgelassen; mit „Erster/Letzter Wert“ sind sie enthalten.
|
|
|
|
|
|
</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('{}')
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ marginTop: 3 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>Eigene Aggregation in dieser Vorlage speichern.</strong> Wenn deaktiviert, gilt der{' '}
|
|
|
|
|
|
<strong>Modul-Standard</strong> (siehe unten) bzw. kein Aggregat, wenn das Modul keinen definiert.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{modMeta.import_row_processing_default && (
|
|
|
|
|
|
<details style={{ marginTop: 12, fontSize: 13, color: 'var(--text2)' }}>
|
|
|
|
|
|
<summary style={{ cursor: 'pointer' }}>Modul-Standard (Referenz, wenn 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)
|
|
|
|
|
|
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 folgt in einer späteren Ausbaustufe).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
minHeight: 160,
|
|
|
|
|
|
marginTop: 8,
|
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
textAlign: 'left',
|
|
|
|
|
|
}}
|
|
|
|
|
|
value={rowAggJsonText}
|
|
|
|
|
|
onChange={(e) => setRowAggJsonText(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-10 09:54:32 +02:00
|
|
|
|
{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 }}>
|
2026-04-10 11:38:54 +02:00
|
|
|
|
Ziel-Einheit kommt aus dem Datenmodell. Standard-Umrechnungen im Dropdown; bei "Benutzerdefiniert"
|
|
|
|
|
|
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.
|
2026-04-10 09:54:32 +02:00
|
|
|
|
</p>
|
2026-04-10 11:19:44 +02:00
|
|
|
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
2026-04-10 09:54:32 +02:00
|
|
|
|
{unitTargets.map(({ field: fkey, options }) => (
|
2026-04-10 11:19:44 +02:00
|
|
|
|
<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' && (
|
2026-04-10 11:38:54 +02:00
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
|
|
|
|
<span className="form-label" style={{ display: 'block', marginBottom: 8 }}>
|
|
|
|
|
|
Bezugsgröße (CSV steht in der linken Einheit)
|
2026-04-10 11:19:44 +02:00
|
|
|
|
</span>
|
2026-04-10 11:38:54 +02:00
|
|
|
|
<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)}
|
2026-04-10 11:19:44 +02:00
|
|
|
|
</p>
|
2026-04-10 11:38:54 +02:00
|
|
|
|
) : 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 g/ml ist. Alle drei Felder leer lassen = keine Zusatz-Umrechnung. Tab /
|
|
|
|
|
|
Fokus weg oder Speichern übernimmt.
|
|
|
|
|
|
</p>
|
2026-04-10 11:19:44 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-04-10 09:54:32 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-10 06:39:41 +02:00
|
|
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
|
|
|
|
<div className="form-label">4. type_conversions (JSON)</div>
|
2026-04-10 10:27:59 +02:00
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}>
|
2026-04-10 11:38:54 +02:00
|
|
|
|
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.
|
2026-04-10 06:39:41 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
2026-04-10 07:08:21 +02:00
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
minHeight: 200,
|
|
|
|
|
|
marginTop: 8,
|
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
textAlign: 'left',
|
|
|
|
|
|
}}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
value={typeConversionsText}
|
2026-04-10 11:25:38 +02:00
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
setTypeConversionsText(e.target.value)
|
2026-04-10 11:38:54 +02:00
|
|
|
|
setCustomEquivalenceDraftByField({})
|
2026-04-10 11:25:38 +02:00
|
|
|
|
}}
|
2026-04-10 06:39:41 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|