2026-04-10 06:15:21 +02:00
|
|
|
|
import { useState, useMemo } from 'react'
|
2026-04-10 06:10:06 +02:00
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
|
|
import { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
|
|
|
|
|
|
import { api } from '../utils/api'
|
|
|
|
|
|
|
2026-04-10 06:15:21 +02:00
|
|
|
|
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
|
|
|
|
|
const EXECUTOR_READY = new Set(['nutrition', 'weight', 'blood_pressure'])
|
|
|
|
|
|
|
|
|
|
|
|
const MODULE_LABEL = {
|
|
|
|
|
|
nutrition: 'Ernährung',
|
|
|
|
|
|
weight: 'Gewicht',
|
|
|
|
|
|
blood_pressure: 'Blutdruck',
|
|
|
|
|
|
activity: 'Aktivität',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-10 06:10:06 +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) => (
|
|
|
|
|
|
<td
|
|
|
|
|
|
key={c}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '6px',
|
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
|
maxWidth: 140,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{row[c] ?? '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function UniversalCsvImportPage() {
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const [file, setFile] = useState(null)
|
|
|
|
|
|
const [analyzeResult, setAnalyzeResult] = useState(null)
|
2026-04-10 06:15:21 +02:00
|
|
|
|
const [mappingChoices, setMappingChoices] = useState([])
|
2026-04-10 06:10:06 +02:00
|
|
|
|
const [mappingId, setMappingId] = useState('')
|
|
|
|
|
|
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
|
|
|
|
|
|
const [loadingImport, setLoadingImport] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
|
|
|
|
|
const [success, setSuccess] = useState(null)
|
|
|
|
|
|
|
2026-04-10 06:15:21 +02:00
|
|
|
|
const selectedChoice = useMemo(
|
|
|
|
|
|
() => mappingChoices.find((c) => String(c.id) === String(mappingId)),
|
|
|
|
|
|
[mappingChoices, mappingId],
|
|
|
|
|
|
)
|
|
|
|
|
|
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
|
2026-04-10 06:10:06 +02:00
|
|
|
|
|
|
|
|
|
|
const handleAnalyze = async () => {
|
2026-04-10 06:15:21 +02:00
|
|
|
|
if (!file) {
|
|
|
|
|
|
setError('Bitte eine CSV-Datei wählen')
|
2026-04-10 06:10:06 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setLoadingAnalyze(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
setSuccess(null)
|
2026-04-10 06:15:21 +02:00
|
|
|
|
setMappingId('')
|
2026-04-10 06:10:06 +02:00
|
|
|
|
try {
|
2026-04-10 06:15:21 +02:00
|
|
|
|
const res = await api.analyzeCsv(file)
|
2026-04-10 06:10:06 +02:00
|
|
|
|
setAnalyzeResult(res)
|
2026-04-10 06:15:21 +02:00
|
|
|
|
const mapData = await api.getCsvMappings()
|
|
|
|
|
|
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)
|
2026-04-10 06:10:06 +02:00
|
|
|
|
}
|
2026-04-10 06:15:21 +02:00
|
|
|
|
setMappingId(pick)
|
2026-04-10 06:10:06 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || 'Analyse fehlgeschlagen')
|
|
|
|
|
|
setAnalyzeResult(null)
|
2026-04-10 06:15:21 +02:00
|
|
|
|
setMappingChoices([])
|
2026-04-10 06:10:06 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingAnalyze(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleImport = async () => {
|
2026-04-10 06:15:21 +02:00
|
|
|
|
if (!file || !mappingId) {
|
|
|
|
|
|
setError('Bitte Datei und Vorlage wählen')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!importAllowed) {
|
|
|
|
|
|
setError('Diese Vorlage kann hier noch nicht importiert werden (z. B. Aktivität → Seite „Aktivität“).')
|
2026-04-10 06:10:06 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setLoadingImport(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
setSuccess(null)
|
|
|
|
|
|
try {
|
2026-04-10 06:15:21 +02:00
|
|
|
|
const res = await api.importUniversalCsv(file, Number(mappingId))
|
2026-04-10 06:10:06 +02:00
|
|
|
|
const st = res.stats || {}
|
2026-04-10 06:15:21 +02:00
|
|
|
|
const modLabel = MODULE_LABEL[res.module] || res.module || ''
|
2026-04-10 06:10:06 +02:00
|
|
|
|
setSuccess(
|
2026-04-10 06:15:21 +02:00
|
|
|
|
(modLabel ? `${modLabel}: ` : '') +
|
|
|
|
|
|
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
|
|
|
|
|
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
|
2026-04-10 06:10:06 +02:00
|
|
|
|
)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || 'Import fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingImport(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="capture-page" style={{ paddingBottom: 88 }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginBottom: 16,
|
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 8,
|
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeft size={18} /> Zurück
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
|
|
|
|
<FileSpreadsheet size={26} strokeWidth={2} />
|
|
|
|
|
|
CSV-Import
|
|
|
|
|
|
</h1>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
|
|
|
|
|
|
Datei hochladen: Die App vergleicht die Spalten-Struktur mit allen gespeicherten Vorlagen und schlägt
|
|
|
|
|
|
passende Ziele vor (Ernährung, Gewicht, Blutdruck, …). Du bestätigst nur noch die Vorlage — ohne
|
|
|
|
|
|
vorher ein „Modul“ raten zu müssen.{' '}
|
|
|
|
|
|
<strong>Später:</strong> eine Datei, mehrere Zieltabellen. Trainingseinheiten aktuell weiter unter{' '}
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ padding: '2px 8px', fontSize: 13 }}
|
|
|
|
|
|
onClick={() => navigate('/activity')}
|
|
|
|
|
|
>
|
|
|
|
|
|
Aktivität
|
|
|
|
|
|
</button>
|
|
|
|
|
|
.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="card"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginBottom: 16,
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
borderColor: 'var(--danger)',
|
|
|
|
|
|
color: 'var(--danger)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{success && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="card"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginBottom: 16,
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
borderColor: 'var(--accent)',
|
|
|
|
|
|
color: 'var(--accent-dark)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{success}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
<div className="form-label">1. CSV-Datei</div>
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".csv,text/csv"
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ marginTop: 8, width: '100%' }}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
setFile(e.target.files?.[0] || null)
|
|
|
|
|
|
setAnalyzeResult(null)
|
2026-04-10 06:15:21 +02:00
|
|
|
|
setMappingChoices([])
|
|
|
|
|
|
setMappingId('')
|
2026-04-10 06:10:06 +02:00
|
|
|
|
setSuccess(null)
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
style={{ marginTop: 12, width: '100%' }}
|
2026-04-10 06:15:21 +02:00
|
|
|
|
disabled={!file || loadingAnalyze}
|
2026-04-10 06:10:06 +02:00
|
|
|
|
onClick={handleAnalyze}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loadingAnalyze ? (
|
|
|
|
|
|
<>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Datei
|
|
|
|
|
|
wird erkannt …
|
2026-04-10 06:10:06 +02:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
2026-04-10 06:15:21 +02:00
|
|
|
|
'Datei analysieren'
|
2026-04-10 06:10:06 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{analyzeResult && (
|
|
|
|
|
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
<div className="form-label">2. Erkennung & Vorschau</div>
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
|
|
|
|
|
|
{analyzeResult.columns?.length ?? 0}
|
2026-04-10 06:10:06 +02:00
|
|
|
|
</div>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
|
|
|
|
|
|
{analyzeResult.recommended && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 12,
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong>Vorschlag:</strong>{' '}
|
|
|
|
|
|
{MODULE_LABEL[analyzeResult.recommended.module] || analyzeResult.recommended.module} —{' '}
|
|
|
|
|
|
{analyzeResult.recommended.mapping_name} (
|
|
|
|
|
|
{Math.round((analyzeResult.recommended.confidence || 0) * 100)} % Übereinstimmung der Spalten)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{(analyzeResult.detected_mappings || []).length > 1 && (
|
|
|
|
|
|
<details style={{ marginTop: 12, fontSize: 13 }}>
|
|
|
|
|
|
<summary style={{ cursor: 'pointer', color: 'var(--text2)' }}>Weitere Treffer</summary>
|
|
|
|
|
|
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
|
|
|
|
|
|
{analyzeResult.detected_mappings.slice(1, 8).map((d) => (
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<li key={d.mapping_id}>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
{MODULE_LABEL[d.module] || d.module}: {d.mapping_name} ·{' '}
|
|
|
|
|
|
{Math.round((d.confidence || 0) * 100)} %
|
2026-04-10 06:10:06 +02:00
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
</details>
|
2026-04-10 06:10:06 +02:00
|
|
|
|
)}
|
2026-04-10 06:15:21 +02:00
|
|
|
|
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="form-label" style={{ marginTop: 16 }}>
|
|
|
|
|
|
Import-Vorlage
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={mappingId}
|
|
|
|
|
|
onChange={(e) => setMappingId(e.target.value)}
|
|
|
|
|
|
style={{ width: '100%', marginTop: 8 }}
|
|
|
|
|
|
>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
{mappingChoices.length === 0 ? (
|
|
|
|
|
|
<option value="">Keine Vorlage geladen</option>
|
2026-04-10 06:10:06 +02:00
|
|
|
|
) : (
|
2026-04-10 06:15:21 +02:00
|
|
|
|
mappingChoices.map((o) => (
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<option key={o.id} value={o.id}>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
{MODULE_LABEL[o.module] || o.module} — {o.name}
|
|
|
|
|
|
{o.is_system ? ' (System)' : ''}
|
|
|
|
|
|
{o.confidence > 0 ? ` · ${Math.round(o.confidence * 100)} %` : ''}
|
|
|
|
|
|
{!EXECUTOR_READY.has(o.module) ? ' · Import: noch nicht hier' : ''}
|
2026-04-10 06:10:06 +02:00
|
|
|
|
</option>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
2026-04-10 06:15:21 +02:00
|
|
|
|
{selectedChoice && !importAllowed && (
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 10 }}>
|
|
|
|
|
|
Diese Vorlage passt strukturell, wird aber an der passenden Stelle importiert (z. B. Aktivität →
|
|
|
|
|
|
Apple-Health-CSV dort).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-10 06:10:06 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
style={{ marginTop: 16, width: '100%' }}
|
2026-04-10 06:15:21 +02:00
|
|
|
|
disabled={!file || !mappingId || !importAllowed || loadingImport}
|
2026-04-10 06:10:06 +02:00
|
|
|
|
onClick={handleImport}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loadingImport ? (
|
|
|
|
|
|
<>
|
2026-04-10 06:15:21 +02:00
|
|
|
|
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import
|
|
|
|
|
|
läuft …
|
2026-04-10 06:10:06 +02:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
'Import starten'
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|