mitai-jinkendo/frontend/src/pages/UniversalCsvImportPage.jsx
Lars 79ec249bc3
Some checks failed
Deploy Development / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Failing after 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s
feat: feature enforcement imports (#37/#38) and Universal CSV validation (#71)
Activity and nutrition legacy CSV imports enforce tier limits with UsageBadge UI; Universal CSV gets mapping validation on copy/import, format-check parity, and structured error_details.

version: 0.9u
module: activity 1.2.1, nutrition 1.0.3, csv_import 0.4.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:59:12 +02:00

568 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
import CsvImportErrorDetails from '../components/CsvImportErrorDetails'
/** 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 (
<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={csvPreviewTdStyle(row[c] ?? '—')}>
{row[c] ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
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 (
<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>
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
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).{' '}
<strong>Ausblick:</strong> Eine Datei mehrere Zieltabellen.
</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>
)}
{lastImport?.error_details?.length > 0 && (
<CsvImportErrorDetails
errors={lastImport.error_details}
title={`Zeilenfehler vom letzten Import (${lastImport.error_details.length})`}
defaultOpen
/>
)}
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">1. CSV-Datei</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="form-input"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
onChange={(e) => assignCsvFile(e.target.files?.[0] || null)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={(e) => {
e.preventDefault()
if (!e.currentTarget.contains(e.relatedTarget)) setDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
assignCsvFile(e.dataTransfer?.files?.[0] || null)
}}
className="btn btn-secondary"
style={{
marginTop: 8,
width: '100%',
minHeight: 120,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
border: `2px dashed ${dragActive ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 12,
background: dragActive ? 'var(--surface2)' : 'var(--surface)',
cursor: 'pointer',
padding: 16,
}}
>
<Upload size={28} strokeWidth={1.75} color="var(--accent)" />
<span style={{ fontSize: 15, color: 'var(--text1)', fontWeight: 600 }}>
Datei ablegen oder tippen zum Auswählen
</span>
<span style={{ fontSize: 13, color: 'var(--text3)' }}>
{loadingAnalyze ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Loader2 size={16} style={{ animation: 'spin 0.7s linear infinite' }} />
Datei wird analysiert
</span>
) : file ? (
file.name
) : (
'Noch keine Datei gewählt'
)}
</span>
</button>
{file && !loadingAnalyze && (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12, width: '100%' }}
onClick={() => void runAnalyze(file)}
>
Dieselbe Datei erneut analysieren
</button>
)}
</div>
{analyzeResult && (
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">2. Erkennung &amp; Vorschau</div>
{(analyzeResult.warnings || []).length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
borderRadius: 12,
background: 'rgba(216, 90, 48, 0.12)',
border: '1px solid var(--danger)',
color: 'var(--danger-dark, var(--danger))',
fontSize: 14,
lineHeight: 1.5,
}}
>
{(analyzeResult.warnings || []).map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
{analyzeResult.columns?.length ?? 0}
{analyzeResult.format_detection?.apple_sleep ? (
<>
{' '}
· <strong>Format:</strong> Apple-Schlaf (Schlafanalyse oder Segment-Export)
</>
) : null}
</div>
{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}.
<br />
<span style={{ fontSize: 13, color: 'var(--text2)', fontWeight: 500 }}>
Vorlage abgedeckt:{' '}
<strong>{Math.round((analyzeResult.recommended.confidence || 0) * 100)} %</strong>
{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{' '}
<strong>{Math.round(analyzeResult.recommended.jaccard * 100)} %</strong> (gesamte
Spalten-Überlappung niedriger, wenn die CSV viele Zusatzspalten hat).
</>
)}
</span>
</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) => (
<li key={d.mapping_id}>
{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)} %` : ''}
</li>
))}
</ul>
</details>
)}
<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,
minHeight: 48,
textAlign: 'left',
padding: '12px 14px',
fontSize: 15,
}}
>
{mappingChoices.length === 0 ? (
<option value="">Keine Vorlage geladen</option>
) : (
mappingChoices.map((o) => (
<option key={o.id} value={o.id}>
{MODULE_LABEL[o.module] || o.module} {o.name}
{o.is_system ? ' (System)' : ''}
{o.confidence > 0
? ` · Vorlage ${Math.round(o.confidence * 100)} %${
o.jaccard != null ? ` · Jaccard ${Math.round(o.jaccard * 100)} %` : ''
}`
: ''}
{!EXECUTOR_READY.has(o.module) ? ' · Import: noch nicht hier' : ''}
</option>
))
)}
</select>
{selectedChoice && !importAllowed && (
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 10 }}>
Diese Vorlage passt strukturell; dieser Import-Weg unterstützt das Zielmodul noch nicht.
</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={!file || !mappingId || !importAllowed || loadingDiagnose}
onClick={() => void runDiagnose()}
>
{loadingDiagnose ? (
<>
<Loader2
size={18}
style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }}
/>{' '}
Diagnose
</>
) : (
'Mapping prüfen (ohne Import)'
)}
</button>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%' }}
disabled={!file || !mappingId || !importAllowed || loadingImport}
onClick={handleImport}
>
{loadingImport ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import
läuft
</>
) : (
'Import starten'
)}
</button>
</div>
{diagnoseResult && (
<details style={{ marginTop: 20 }} open>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text2)' }}>
Diagnose-Ergebnis ({diagnoseResult.rows_diagnosed ?? 0} Zeilen)
</summary>
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8, lineHeight: 1.5 }}>
Vorlage #{diagnoseResult.mapping_id} · {diagnoseResult.mapping_name} · Modul{' '}
{MODULE_LABEL[diagnoseResult.module] || diagnoseResult.module}. Hinweise: Vitalwerte{' '}
<code>vitals.*</code>, Blutdruck <code>blood_pressure.*</code>, Workouts{' '}
<code>activity.*</code> (z.B. <code>would_pass_row_gate</code> /{' '}
<code>prefilter_fail_reason</code>).
</p>
<pre
style={{
marginTop: 8,
fontSize: 11,
overflow: 'auto',
maxHeight: 480,
background: 'var(--surface2)',
padding: 12,
borderRadius: 8,
color: 'var(--text1)',
}}
>
{JSON.stringify(diagnoseResult, null, 2)}
</pre>
</details>
)}
</div>
)}
</div>
)
}