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) => ( + + ))} + + + + {sampleRows.slice(0, 5).map((row, i) => ( + + {showCols.map((c) => ( + + ))} + + ))} + +
+ {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 +
+ + + +
+ )} +
+ ) +}