import { useState, useMemo, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react' import { api } from '../utils/api' import { csvPreviewTdStyle } from '../utils/csvPreviewCells' /** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */ const EXECUTOR_READY = new Set([ 'nutrition', 'weight', 'blood_pressure', 'activity', 'sleep', 'vitals_baseline', ]) const MODULE_LABEL = { nutrition: 'Ernährung', weight: 'Gewicht', blood_pressure: 'Blutdruck', activity: 'Aktivität', sleep: 'Schlaf', vitals_baseline: 'Vitalwerte (Baseline)', } function mergeMappingChoices(detected, mapData) { const sys = mapData.system_templates || [] const usr = mapData.user_mappings || [] const flat = [ ...sys.map((m) => ({ ...m, _isSys: true })), ...usr.map((m) => ({ ...m, _isSys: false })), ] const byId = new Map(flat.map((m) => [m.id, m])) const out = [] const seen = new Set() for (const d of detected || []) { const row = byId.get(d.mapping_id) if (row) { out.push({ id: row.id, module: row.module, name: row.name, is_system: row.is_system, confidence: d.confidence ?? 0, jaccard: d.jaccard, template_recall: d.template_recall, columns_matched: d.columns_matched, columns_in_template: d.columns_in_template, columns_in_csv: d.columns_in_csv, }) seen.add(row.id) } } for (const row of flat) { if (!seen.has(row.id)) { out.push({ id: row.id, module: row.module, name: row.name, is_system: row.is_system, confidence: 0, }) } } return out } 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 fileInputRef = useRef(null) const analyzeGenRef = useRef(0) const [file, setFile] = useState(null) const [dragActive, setDragActive] = useState(false) const [analyzeResult, setAnalyzeResult] = useState(null) const [mappingChoices, setMappingChoices] = useState([]) const [mappingId, setMappingId] = useState('') const [loadingAnalyze, setLoadingAnalyze] = useState(false) const [loadingImport, setLoadingImport] = useState(false) const [loadingDiagnose, setLoadingDiagnose] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [lastImport, setLastImport] = useState(null) const [diagnoseResult, setDiagnoseResult] = useState(null) const selectedChoice = useMemo( () => mappingChoices.find((c) => String(c.id) === String(mappingId)), [mappingChoices, mappingId], ) const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module) const runAnalyze = async (fileToAnalyze) => { if (!fileToAnalyze) return const gen = ++analyzeGenRef.current setLoadingAnalyze(true) setError(null) setSuccess(null) setMappingId('') try { const res = await api.analyzeCsv(fileToAnalyze) if (gen !== analyzeGenRef.current) return setAnalyzeResult(res) const mapData = await api.getCsvMappings() if (gen !== analyzeGenRef.current) return const merged = mergeMappingChoices(res.detected_mappings || [], mapData) setMappingChoices(merged) const best = res.recommended || (res.detected_mappings || [])[0] let pick = '' if (best && merged.some((m) => m.id === best.mapping_id)) { pick = String(best.mapping_id) } else if (merged.length) { const firstReady = merged.find((m) => EXECUTOR_READY.has(m.module)) pick = String((firstReady || merged[0]).id) } setMappingId(pick) } catch (e) { if (gen !== analyzeGenRef.current) return setError(e.message || 'Analyse fehlgeschlagen') setAnalyzeResult(null) setMappingChoices([]) } finally { if (gen === analyzeGenRef.current) { setLoadingAnalyze(false) } } } const assignCsvFile = (f) => { if (!f) return const name = (f.name || '').toLowerCase() if (!name.endsWith('.csv')) { setError('Bitte eine .csv-Datei wählen.') return } setFile(f) setAnalyzeResult(null) setMappingChoices([]) setMappingId('') setSuccess(null) setError(null) setLastImport(null) setDiagnoseResult(null) void runAnalyze(f) } const handleImport = async () => { if (!file || !mappingId) { setError('Bitte Datei und Vorlage wählen') return } if (!importAllowed) { setError('Diese Vorlage wird vom Universal-Importer noch nicht unterstützt.') return } setLoadingImport(true) setError(null) setSuccess(null) try { const res = await api.importUniversalCsv(file, Number(mappingId)) setLastImport(res) const st = res.stats || {} const modLabel = MODULE_LABEL[res.module] || res.module || '' setSuccess( (modLabel ? `${modLabel}: ` : '') + `Import fertig — ${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) } } const runDiagnose = async () => { if (!file || !mappingId) { setError('Bitte Datei und Vorlage wählen') return } setLoadingDiagnose(true) setError(null) setDiagnoseResult(null) try { const res = await api.diagnoseUniversalCsv(file, Number(mappingId)) setDiagnoseResult(res) } catch (e) { setError(e.message || 'Diagnose fehlgeschlagen') } finally { setLoadingDiagnose(false) } } return (

CSV-Import

CSV hier ablegen oder die Fläche antippen — die Analyse startet sofort. Die App vergleicht die Spalten mit gespeicherten Vorlagen (Ernährung, Gewicht, Blutdruck, Aktivität, Schlaf, Vitalwerte) und schlägt passende Ziele vor. Du bestätigst die Vorlage — ohne zuerst ein Modell raten zu müssen. Schlaf-Import erwartet das Apple-Health-Schlaf-CSV (Segment- oder Zusammenfassungs-Export).{' '} Ausblick: Eine Datei → mehrere Zieltabellen.

{error && (
{error}
)} {success && (
{success}
)} {lastImport?.error_details?.length > 0 && (
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) — zum Kopieren aufklappen
            {JSON.stringify(lastImport.error_details, null, 2)}
          
)}
1. CSV-Datei
assignCsvFile(e.target.files?.[0] || null)} /> {file && !loadingAnalyze && ( )}
{analyzeResult && (
2. Erkennung & Vorschau
{(analyzeResult.warnings || []).length > 0 && (
{(analyzeResult.warnings || []).map((w, i) => (
{w}
))}
)}
Trennzeichen: {analyzeResult.delimiter} · Spalten:{' '} {analyzeResult.columns?.length ?? 0} {analyzeResult.format_detection?.apple_sleep ? ( <> {' '} · Format: Apple-Schlaf (Schlafanalyse oder Segment-Export) ) : null}
{analyzeResult.recommended && (
Vorschlag:{' '} {MODULE_LABEL[analyzeResult.recommended.module] || analyzeResult.recommended.module} —{' '} {analyzeResult.recommended.mapping_name}.
Vorlage abgedeckt:{' '} {Math.round((analyzeResult.recommended.confidence || 0) * 100)} % {analyzeResult.recommended.columns_matched != null && analyzeResult.recommended.columns_in_template != null ? ` (${analyzeResult.recommended.columns_matched}/${analyzeResult.recommended.columns_in_template} erwartete Spalten in der Datei)` : ''} .{' '} {analyzeResult.recommended.jaccard != null && ( <> Jaccard{' '} {Math.round(analyzeResult.recommended.jaccard * 100)} % (gesamte Spalten-Überlappung — niedriger, wenn die CSV viele Zusatzspalten hat). )}
)} {(analyzeResult.detected_mappings || []).length > 1 && (
Weitere Treffer
    {analyzeResult.detected_mappings.slice(1, 8).map((d) => (
  • {MODULE_LABEL[d.module] || d.module}: {d.mapping_name} · Vorlage{' '} {Math.round((d.confidence || 0) * 100)} % {d.jaccard != null ? ` · Jaccard ${Math.round(d.jaccard * 100)} %` : ''}
  • ))}
)}
Import-Vorlage
{selectedChoice && !importAllowed && (

Diese Vorlage passt strukturell; dieser Import-Weg unterstützt das Zielmodul noch nicht.

)}
{diagnoseResult && (
Diagnose-Ergebnis ({diagnoseResult.rows_diagnosed ?? 0} Zeilen)

Vorlage #{diagnoseResult.mapping_id} · {diagnoseResult.mapping_name} · Modul{' '} {MODULE_LABEL[diagnoseResult.module] || diagnoseResult.module}. Hinweise: Vitalwerte{' '} vitals.*, Blutdruck blood_pressure.*, Workouts{' '} activity.* (z. B. would_pass_row_gate /{' '} prefilter_fail_reason).

                {JSON.stringify(diagnoseResult, null, 2)}
              
)}
)}
) }