diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e4cfbbd..2e85b01 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -53,6 +53,7 @@ import RestDaysPage from './pages/RestDaysPage'
import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage'
import CustomGoalsPage from './pages/CustomGoalsPage'
+import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
import WorkflowEditorPage from './pages/WorkflowEditorPage'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
@@ -225,6 +226,7 @@ function AppShell() {
} />
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/config/captureNav.js b/frontend/src/config/captureNav.js
index 58471c9..f815670 100644
--- a/frontend/src/config/captureNav.js
+++ b/frontend/src/config/captureNav.js
@@ -40,6 +40,13 @@ export const CAPTURE_HUB_TILES = [
to: '/nutrition',
color: '#EF9F27',
},
+ {
+ icon: '📥',
+ label: 'CSV-Import',
+ sub: 'Vorlagen für Ernährung, Gewicht, Blutdruck',
+ to: '/csv-import',
+ color: '#2E7D32',
+ },
{
icon: '🏋️',
label: 'Aktivität',
diff --git a/frontend/src/pages/UniversalCsvImportPage.jsx b/frontend/src/pages/UniversalCsvImportPage.jsx
new file mode 100644
index 0000000..daf4b24
--- /dev/null
+++ b/frontend/src/pages/UniversalCsvImportPage.jsx
@@ -0,0 +1,366 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
+import { api } from '../utils/api'
+
+/** Module mit funktionierendem Universal-Executor; Aktivität: Legacy-Import auf der Aktivitätsseite. */
+const UNIVERSAL_IMPORT_IDS = new Set(['nutrition', 'weight', 'blood_pressure'])
+
+function SampleTable({ sampleRows, columns }) {
+ if (!sampleRows?.length || !columns?.length) return null
+ const showCols = columns.slice(0, 8)
+ return (
+
+
+
+
+ {showCols.map((c) => (
+ |
+ {c}
+ |
+ ))}
+
+
+
+ {sampleRows.slice(0, 5).map((row, i) => (
+
+ {showCols.map((c) => (
+ |
+ {row[c] ?? '—'}
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+export default function UniversalCsvImportPage() {
+ const navigate = useNavigate()
+ const [modules, setModules] = useState([])
+ const [moduleId, setModuleId] = useState('')
+ const [file, setFile] = useState(null)
+ const [analyzeResult, setAnalyzeResult] = useState(null)
+ const [mappingsList, setMappingsList] = useState({ system_templates: [], user_mappings: [] })
+ const [mappingId, setMappingId] = useState('')
+ const [loadingModules, setLoadingModules] = useState(true)
+ const [loadingAnalyze, setLoadingAnalyze] = useState(false)
+ const [loadingImport, setLoadingImport] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+
+ const loadMappings = useCallback(async (mid) => {
+ if (!mid) {
+ setMappingsList({ system_templates: [], user_mappings: [] })
+ return
+ }
+ const data = await api.getCsvMappings(mid)
+ setMappingsList({
+ system_templates: data.system_templates || [],
+ user_mappings: data.user_mappings || [],
+ })
+ }, [])
+
+ useEffect(() => {
+ let cancel = false
+ ;(async () => {
+ setLoadingModules(true)
+ setError(null)
+ try {
+ const data = await api.getCsvModules()
+ const list = (data.modules || []).filter((m) => UNIVERSAL_IMPORT_IDS.has(m.id))
+ if (!cancel) {
+ setModules(list)
+ if (list.length && !moduleId) setModuleId(list[0].id)
+ }
+ } catch (e) {
+ if (!cancel) setError(e.message || 'Module konnten nicht geladen werden')
+ } finally {
+ if (!cancel) setLoadingModules(false)
+ }
+ })()
+ return () => {
+ cancel = true
+ }
+ }, [])
+
+ useEffect(() => {
+ if (moduleId) {
+ loadMappings(moduleId)
+ setAnalyzeResult(null)
+ setMappingId('')
+ setSuccess(null)
+ }
+ }, [moduleId, loadMappings])
+
+ const allMappingOptions = [
+ ...(mappingsList.system_templates || []).map((m) => ({
+ id: m.id,
+ label: `${m.name} (System)`,
+ isSystem: true,
+ })),
+ ...(mappingsList.user_mappings || []).map((m) => ({
+ id: m.id,
+ label: m.name,
+ isSystem: false,
+ })),
+ ]
+
+ const handleAnalyze = async () => {
+ if (!file || !moduleId) {
+ setError('Bitte Modul und Datei wählen')
+ return
+ }
+ setLoadingAnalyze(true)
+ setError(null)
+ setSuccess(null)
+ try {
+ const res = await api.analyzeCsv(file, moduleId)
+ setAnalyzeResult(res)
+ const mapData = await api.getCsvMappings(moduleId)
+ const sys = mapData.system_templates || []
+ const usr = mapData.user_mappings || []
+ setMappingsList({ system_templates: sys, user_mappings: usr })
+ const options = [
+ ...sys.map((m) => ({ id: m.id, name: m.name, isSystem: true })),
+ ...usr.map((m) => ({ id: m.id, name: m.name, isSystem: false })),
+ ]
+ const detected = res.detected_mappings || []
+ const best = detected[0]
+ let pick = options[0]?.id
+ if (best && (best.confidence || 0) >= 0.5 && options.some((o) => o.id === best.mapping_id)) {
+ pick = best.mapping_id
+ }
+ if (pick != null) setMappingId(String(pick))
+ else setMappingId('')
+ } catch (e) {
+ setError(e.message || 'Analyse fehlgeschlagen')
+ setAnalyzeResult(null)
+ } finally {
+ setLoadingAnalyze(false)
+ }
+ }
+
+ const handleImport = async () => {
+ if (!file || !moduleId || !mappingId) {
+ setError('Bitte Datei, Modul und Vorlage wählen')
+ return
+ }
+ setLoadingImport(true)
+ setError(null)
+ setSuccess(null)
+ try {
+ const res = await api.importUniversalCsv(file, moduleId, Number(mappingId))
+ const st = res.stats || {}
+ setSuccess(
+ `Import abgeschlossen: ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
+ `${st.skipped ?? 0} ĂĽbersprungen, ${st.errors ?? 0} Zeilenfehler.`
+ )
+ } catch (e) {
+ setError(e.message || 'Import fehlgeschlagen')
+ } finally {
+ setLoadingImport(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ CSV-Import
+
+
+ Bekannte Formate (FDDB, Apple Health, Omron, …) per Vorlage zuordnen und importieren.
+ Trainingseinheiten weiterhin unter{' '}
+
+ .
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {success && (
+
+ {success}
+
+ )}
+
+
+
1. Modul
+ {loadingModules ? (
+
+ Lade …
+
+ ) : (
+
+ )}
+
+
+
+
2. CSV-Datei
+
{
+ setFile(e.target.files?.[0] || null)
+ setAnalyzeResult(null)
+ setSuccess(null)
+ }}
+ />
+
+
+
+ {analyzeResult && (
+
+
3. Vorschau
+
+ Trennzeichen: {analyzeResult.delimiter} · Spalten: {analyzeResult.columns?.length ?? 0}
+
+ {(analyzeResult.detected_mappings || []).length > 0 && (
+
+
Erkannte Vorlagen:
+
+ {analyzeResult.detected_mappings.map((d) => (
+ -
+ {d.mapping_name} · Übereinstimmung ca. {Math.round((d.confidence || 0) * 100)} %
+
+ ))}
+
+
+ )}
+
+
+
+ Import-Vorlage
+
+
+
+
+
+ )}
+
+ )
+}